============================================================================== HEDERA PROMPTS — X402 PAY-PER-USE (HBAR + HTS USDC) Generated 2026-08-01T02:33:10.182Z · https://hederaprompts.lovable.app/llms ============================================================================== Every megaprompt below is self-contained: it carries the Hedera testnet network facts, the ~900k gas rule for relay transfers and contract writes, the HTS alias-vs-long-zero address rule, mirror-node verification with retries, and revert decoding. Paste one prompt as a single message to your coding agent. ============================================================================== THEME · Dance & Choreography choreographers, dancers, dance teachers, movement directors ============================================================================== ------------------------------------------------------------------------------ IDEA dance-choreo-ledger-0-x402 Title: GLIDE · x402 Theme: Dance & Choreography (dance) · movement attribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A movement-fingerprinting engine where choreographers monetize their 'signature' steps. Developers and AI animators pay $0.01 USDC to query the ledger to verify a sequence's provenance or license a loop for digital reconstruction. Every API call generates a micro-royalty for the original dancer, turning movement data into a liquid asset class. Why Hedera: Current social platforms monetize dance through engagement, but the creator sees zero per-use value. x402 enables 'pay-per-scan' attribution, allowing creators to bill AI training models or VR developers every time their specific movement patterns are accessed or validated. Market: TAM $2.8B — The global animation and motion capture industry integrated with AI agent-driven content creation. | SAM $140M — The choreographer and commercial dance licensing market transitioning to digital assets. | SOM $8M — Micro-licensing for TikTok/Reels trends and indie game character animations. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GLIDE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A movement-fingerprinting engine where choreographers monetize their 'signature' steps. Developers and AI animators pay $0.01 USDC to query the ledger to verify a sequence's provenance or license a loop for digital reconstruction. Every API call generates a micro-royalty for the original dancer, turning movement data into a liquid asset class. Discipline: Dance & Choreography (movement attribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current social platforms monetize dance through engagement, but the creator sees zero per-use value. x402 enables 'pay-per-scan' attribution, allowing creators to bill AI training models or VR developers every time their specific movement patterns are accessed or validated. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GLIDE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-nft-tickets-1-x402 Title: FloorTime · x402 Theme: Dance & Choreography (dance) · event access control Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Replace static ticketing with millisecond-exact event access. Dancers and spectators pay 0.01 USDC per minute or per routine-unlock via x402 stream. Facilitators verify real-time residency in the 'Step-Zone', settling micro-dues to choreographers. No more lump-sum fraud; pay only for the heat you witness. Why Hedera: Moving from NFT ticketing to x402-metered access eliminates the secondary market resale problem entirely by turning access into a real-time, pay-as-you-stay utility. It aligns the cost of the event with the duration of the experience. Market: TAM $12.5B — The global live event and performing arts admission market. | SAM $420M — The global competitive dance and workshop circuit transitioning to micro-access. | SOM $18M — Underground ballroom and street dance battles on Hedera looking for low-friction entry. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FloorTime" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Replace static ticketing with millisecond-exact event access. Dancers and spectators pay 0.01 USDC per minute or per routine-unlock via x402 stream. Facilitators verify real-time residency in the 'Step-Zone', settling micro-dues to choreographers. No more lump-sum fraud; pay only for the heat you witness. Discipline: Dance & Choreography (event access control). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from NFT ticketing to x402-metered access eliminates the secondary market resale problem entirely by turning access into a real-time, pay-as-you-stay utility. It aligns the cost of the event with the duration of the experience. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FloorTime" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movecoin-rewards-2-x402 Title: PRIMA · x402 Theme: Dance & Choreography (dance) · incentive tokenization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A motion-capture validation engine that unlocks micro-payouts for precision. Dancers sign a 0.01 USDC HTS transfer intent to 'Check-In' or 'Verify Sequence'. Instead of vague rewards, students pay a cent to have their form verified by an AI-choreographer, which then triggers a facilitator-settled rebate or 'bounty' back to their wallet upon success. Payment is the proof-of-work for the movement. Why Hedera: Shifts from a passive 'earn' model to an active 'metered validation' model. By charging 0.01 USDC to process a verification, the app prevents sybil-spamming of rewards and creates a high-fidelity audit trail of studio participation on Hedera. Market: TAM $4.5B — The global 'Move-to-Earn' and digital fitness coaching market. | SAM $180M — Competitive ballroom, urban dance, and technical studio sectors globally. | SOM $12M — Early adopter choreography apps and tech-forward dance studios using motion-capture APIs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PRIMA" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A motion-capture validation engine that unlocks micro-payouts for precision. Dancers sign a 0.01 USDC HTS transfer intent to 'Check-In' or 'Verify Sequence'. Instead of vague rewards, students pay a cent to have their form verified by an AI-choreographer, which then triggers a facilitator-settled rebate or 'bounty' back to their wallet upon success. Payment is the proof-of-work for the movement. Discipline: Dance & Choreography (incentive tokenization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from a passive 'earn' model to an active 'metered validation' model. By charging 0.01 USDC to process a verification, the app prevents sybil-spamming of rewards and creates a high-fidelity audit trail of studio participation on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PRIMA" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreo-royalty-pool-3-x402 Title: EightCount · x402 Theme: Dance & Choreography (dance) · rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Every time a dancer performs your routine for a TikTok or rehearsal, the x402 protocol triggers a micropayment to the original creator. Move-to-mint your rights. A dance-move library where every 'unlock' of a choreography breakdown settles 0.01 USDC instantly to the choreographer, dancer, and track producer. Stop chasing legacy royalty checks; start metering the eighth counts. Why Hedera: Traditional choreography royalties are impossible to track at the social media scale. By shifting to a pay-per-view/pay-per-learn model via x402, we turn choreography into a granular digital asset. Each tutorial view or performance authorization becomes a micro-transaction, solving the attribution crisis for viral dances. Market: TAM $3.8B — The global dance instruction and intellectual property licensing market, integrated with automated social media performance tracking. | SAM $450M — The creator economy segment specifically focused on dance tutorials, fitness routines, and social media viral marketing agencies. | SOM $12M — Early-adopter choreographers and professional dance studios on Hedera using automated instruction tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "EightCount" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Every time a dancer performs your routine for a TikTok or rehearsal, the x402 protocol triggers a micropayment to the original creator. Move-to-mint your rights. A dance-move library where every 'unlock' of a choreography breakdown settles 0.01 USDC instantly to the choreographer, dancer, and track producer. Stop chasing legacy royalty checks; start metering the eighth counts. Discipline: Dance & Choreography (rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional choreography royalties are impossible to track at the social media scale. By shifting to a pay-per-view/pay-per-learn model via x402, we turn choreography into a granular digital asset. Each tutorial view or performance authorization becomes a micro-transaction, solving the attribution crisis for viral dances. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "EightCount" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-syncstage-auction-4-x402 Title: Kinetic · x402 Theme: Dance & Choreography (dance) · live collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized stage where every 8-count is a transaction. Choreographers bid $0.01 USDC per 'Move Token' to push live direction to a connected dancer's haptic suit or AR HUD. The HTS transfer signature authorizes instant pose-override, creating a real-time, high-frequency auction for creative control. Why Hedera: By turning choreography into a metered stream of micro-bids, the dancer is compensated per instruction. The x402 mechanism allows for the millisecond latency required for live timing, settling the cumulative performance fee at the end of the set. Market: TAM $850M — The global digital dance and remote fitness instruction market transitioning to pay-per-pose models. | SAM $45M — Professional dancers and experimental theater companies utilizing remote direction. | SOM $1.2M — Early adopters in the 'On-chain Performance Art' niche using Base/HashPack for low-gas live interactions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized stage where every 8-count is a transaction. Choreographers bid $0.01 USDC per 'Move Token' to push live direction to a connected dancer's haptic suit or AR HUD. The HTS transfer signature authorizes instant pose-override, creating a real-time, high-frequency auction for creative control. Discipline: Dance & Choreography (live collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning choreography into a metered stream of micro-bids, the dancer is compensated per instruction. The x402 mechanism allows for the millisecond latency required for live timing, settling the cumulative performance fee at the end of the set. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-moveproof-ledger-5-x402 Title: KineticSafe · x402 Theme: Dance & Choreography (dance) · movement authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographic 'Shazam' for dancers. $0.01 USDC to timestamp a motion sequence or verify a routine's lineage against a global movement database. Stop choreographic drift and attribution loss in viral trends by making authenticity a metered primitive. Why Hedera: Movement is the only digital asset still prone to 'Colloquial Theft' (TikTok trends). x402 turns every motion-capture upload or verification check into a micro-transaction, ensuring creators are paid per validation and plagiarizers are caught by the protocol. Market: TAM $4.2B — The creator economy intellectual property and digital rights management sector. | SAM $850M — The global dance education and digital choreography licensing market. | SOM $12M — Professional choreographers and viral influencers securing IP rights on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KineticSafe" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographic 'Shazam' for dancers. $0.01 USDC to timestamp a motion sequence or verify a routine's lineage against a global movement database. Stop choreographic drift and attribution loss in viral trends by making authenticity a metered primitive. Discipline: Dance & Choreography (movement authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Movement is the only digital asset still prone to 'Colloquial Theft' (TikTok trends). x402 turns every motion-capture upload or verification check into a micro-transaction, ensuring creators are paid per validation and plagiarizers are caught by the protocol. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KineticSafe" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-tokenized-choreo-kits-6-x402 Title: Kinetic Flow · x402 Theme: Dance & Choreography (dance) · content packaging Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A choreography-on-demand protocol where dancers pay per movement-sequence unlock. Instead of bulky licenses, creators package 8-count phrases as x402-native micro-assets. Students pay 0.01 USDC to view the mirror-view tutorial, and influencers pay 0.01 USDC to 'clear' the move for their next short-form video. Each loop is a transaction. Why Hedera: By moving from 'bundles' to 'per-count' micropayments, creators capture value from low-intent users (casual learners) while automating usage rights for high-frequency creators via on-chain settlement. Market: TAM $1.4B — The intersection of the Creator Economy and the Global Dance Education market. | SAM $85M — Focus on global urban dance communities and TikTok trend-setters consuming digital tutorials. | SOM $4.2M — Targeted at professional choreographers transitioning from subscription platforms (Patreon/Stezy) to pay-per-loop models. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Flow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A choreography-on-demand protocol where dancers pay per movement-sequence unlock. Instead of bulky licenses, creators package 8-count phrases as x402-native micro-assets. Students pay 0.01 USDC to view the mirror-view tutorial, and influencers pay 0.01 USDC to 'clear' the move for their next short-form video. Each loop is a transaction. Discipline: Dance & Choreography (content packaging). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'bundles' to 'per-count' micropayments, creators capture value from low-intent users (casual learners) while automating usage rights for high-frequency creators via on-chain settlement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Flow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dancedao-collective-7-x402 Title: FloorControl · x402 Theme: Dance & Choreography (dance) · community governance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-governance layer for dance troupes. Dancers pay 0.01 USDC to cast weighted votes on choreography, set lists, or studio rentals. Instead of monthly dues, users pay for 'active voice'—ensuring that those funding the space decide its direction. Revenue is streamed instantly to studio landlords or costume vendors via the facilitator. Why Hedera: Traditional DAOs suffer from voter apathy and gas costs. By using x402, we turn governance into a granular, high-frequency activity. It commoditizes 'the vote' as a low-friction micropayment, making collective decision-making as easy as liking a post. Market: TAM $2.1B — The global amateur and professional performing arts management market transitioning to digital-first coordination. | SAM $180M — Competitive dance teams, urban dance studios, and independent choreographers globally. | SOM $4.2M — Early-adopter 'Crew' collectives and rehearsal spaces in tech-forward hubs like LA and Seoul. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FloorControl" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-governance layer for dance troupes. Dancers pay 0.01 USDC to cast weighted votes on choreography, set lists, or studio rentals. Instead of monthly dues, users pay for 'active voice'—ensuring that those funding the space decide its direction. Revenue is streamed instantly to studio landlords or costume vendors via the facilitator. Discipline: Dance & Choreography (community governance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional DAOs suffer from voter apathy and gas costs. By using x402, we turn governance into a granular, high-frequency activity. It commoditizes 'the vote' as a low-friction micropayment, making collective decision-making as easy as liking a post. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FloorControl" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-nft-move-badges-8-x402 Title: STANCE · x402 Theme: Dance & Choreography (dance) · achievement tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered verification protocol for dance mastery. Dancers pay 0.01 USDC to submit a motion-recorded skill attempted via camera or wearable sensor. Upon successful algorithmic validation, a permanent on-chain achievement is minted to their Magic Link email sign-in. Payment acts as the 'exam fee' for verifiable proof of work, preventing badge sybils and spamming milestones. Why Hedera: Traditional 'unlocks' are passive; x402 turns achievement tracking into an active, high-integrity transaction. By charging per verification attempt, the system creates a genuine 'proof of effort' economy where a badge represents a specific, paid-for audit of skill rather than a free data point. Market: TAM $140B - The broader 'Creator & Skill Certification' economy where micro-credentials replace traditional diplomas. | SAM $4.2B - Global online dance education and fitness coaching market transitioning to digital credentials. | SOM $85M - Serious dance students and competitive learners using mobile apps for skill progression and rank tracking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STANCE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered verification protocol for dance mastery. Dancers pay 0.01 USDC to submit a motion-recorded skill attempted via camera or wearable sensor. Upon successful algorithmic validation, a permanent on-chain achievement is minted to their Magic Link email sign-in. Payment acts as the 'exam fee' for verifiable proof of work, preventing badge sybils and spamming milestones. Discipline: Dance & Choreography (achievement tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional 'unlocks' are passive; x402 turns achievement tracking into an active, high-integrity transaction. By charging per verification attempt, the system creates a genuine 'proof of effort' economy where a badge represents a specific, paid-for audit of skill rather than a free data point. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STANCE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreo-licensing-hub-9-x402 Title: StepCheck · x402 Theme: Dance & Choreography (dance) · rights marketplace Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-licensing engine for viral movement. Instead of complex legal sync-rights, dancers sign HTS transfer permits to authorize 8-bar choreography sequences. Creators pay 0.01 USDC to 'unlock' the official instructional breakdown and mirror-permission, with the facilitator settling fees to the choreographer in real-time. Every tutorial view or 'duet' attempt triggers a micro-transaction, turning viral trends into programmatic revenue streams. Why Hedera: Choreography rights are currently unenforceable at scale. By turning 'steps' into metered x402 calls, we move from unenforceable 'terms of service' to a pay-per-frame utility model where usage and payment are atomically linked. Market: TAM $4.2B — The global dance and performance art instruction market transitioning to digital-first, decentralized distribution. | SAM $850M — The total spent by micro-influencers and brands on licensed audio and visual assets for short-form video. | SOM $12M — Initial volume from professional TikTok choreographers and dance studios seeking automated royalty collection. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StepCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-licensing engine for viral movement. Instead of complex legal sync-rights, dancers sign HTS transfer permits to authorize 8-bar choreography sequences. Creators pay 0.01 USDC to 'unlock' the official instructional breakdown and mirror-permission, with the facilitator settling fees to the choreographer in real-time. Every tutorial view or 'duet' attempt triggers a micro-transaction, turning viral trends into programmatic revenue streams. Discipline: Dance & Choreography (rights marketplace). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Choreography rights are currently unenforceable at scale. By turning 'steps' into metered x402 calls, we move from unenforceable 'terms of service' to a pay-per-frame utility model where usage and payment are atomically linked. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StepCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dancemove-provenance-10-x402 Title: KINETIC · x402 Theme: Dance & Choreography (dance) · creative lineage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for pay-per-credit dance education where choreographers receive 0.01 USDC every time a user unlocks a step or views the 'Original Sequence' metadata. x402 settles the micro-royalty instantly, allowing students to map their stylistic lineage by micro-paying for the 'source' of their inspiration. The payment is the proof-of-influence. Why Hedera: Transforms attribution from a passive label to an active micro-transaction. By making the lineage gated by a 1-cent fee, it creates a high-velocity revenue stream for original creators while providing an immutable 'on-chain receipt' for the student's creative heritage. Market: TAM $3.2B — The global social media creator economy and professional choreography licensing industry. | SAM $450M — The digital dance instruction and choreography notation market. | SOM $18M — Micro-payments for short-form video choreographers and urban dance educators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for pay-per-credit dance education where choreographers receive 0.01 USDC every time a user unlocks a step or views the 'Original Sequence' metadata. x402 settles the micro-royalty instantly, allowing students to map their stylistic lineage by micro-paying for the 'source' of their inspiration. The payment is the proof-of-influence. Discipline: Dance & Choreography (creative lineage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transforms attribution from a passive label to an active micro-transaction. By making the lineage gated by a 1-cent fee, it creates a high-velocity revenue stream for original creators while providing an immutable 'on-chain receipt' for the student's creative heritage. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-livemotion-payments-11-x402 Title: PLIÉ · x402 Theme: Dance & Choreography (dance) · instant settlement Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity motion-tracking API that settles royalties per '8-count'. Dancers upload choreography primitives; creators and developers pay 0.01 USDC to unlock specific sequences for their avatars or rehearsal playback. No subscriptions, just pay-to-play motion data. Why Hedera: By atomizing choreography into individual moves or sequences (micropayments), we remove the friction of hiring a choreographer for a full session. Payment becomes the 'play' button for physical data. Market: TAM $4.2B — The global animation, gaming, and digital creator economy. | SAM $280M — Professional choreographers, TikTok creators, and indie game devs buying move-sets. | SOM $12M — Web3 creators and AR developers building dance-responsive apps on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PLIÉ" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity motion-tracking API that settles royalties per '8-count'. Dancers upload choreography primitives; creators and developers pay 0.01 USDC to unlock specific sequences for their avatars or rehearsal playback. No subscriptions, just pay-to-play motion data. Discipline: Dance & Choreography (instant settlement). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By atomizing choreography into individual moves or sequences (micropayments), we remove the friction of hiring a choreographer for a full session. Payment becomes the 'play' button for physical data. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PLIÉ" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreocrowd-fund-12-x402 Title: StepStream · x402 Theme: Dance & Choreography (dance) · project crowdfunding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity choreography vault where every 'Step-Review' and 'Rehearsal-Unlock' is a direct 0.01 USDC micropayment to the choreographer. Instead of waiting for a goal to be met, supporters fund the process in real-time, paying per view or per motion-data download. Milestone completion triggers batch distributions, turning fans into active patrons of every 8-count. Why Hedera: Traditional crowdfunding is binary (success or failure). x402 turns the creative process into a metered stream. By charging 0.01 USDC per frame or milestone update, choreographers gain immediate liquidity, and backers gain granular access without heavy upfront commitment. Market: TAM $5.2B — The global dance industry, including studio rentals, commercial choreography, and digital content rights. | SAM $450M — The digital dance education and professional choreography licensing sector. | SOM $12M — Independent urban and contemporary choreographers using Base for direct-to-fan distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StepStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity choreography vault where every 'Step-Review' and 'Rehearsal-Unlock' is a direct 0.01 USDC micropayment to the choreographer. Instead of waiting for a goal to be met, supporters fund the process in real-time, paying per view or per motion-data download. Milestone completion triggers batch distributions, turning fans into active patrons of every 8-count. Discipline: Dance & Choreography (project crowdfunding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional crowdfunding is binary (success or failure). x402 turns the creative process into a metered stream. By charging 0.01 USDC per frame or milestone update, choreographers gain immediate liquidity, and backers gain granular access without heavy upfront commitment. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StepStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dancestep-identity-13-x402 Title: KineticID · x402 Theme: Dance & Choreography (dance) · digital identity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-credentialing layer for motion. Pay 0.05 USDC to mint a verifiable 'Step-ID' claim (e.g., a specific flip, a battle win, or a choreography suite). Talent scouts and DAOs pay 0.01 USDC to query a dancer’s performance history, ensuring they are hiring based on cryptographically proven skill, not just social media clout. Every unlock of your professional profile funnels a micropayment back to the dancer. Why Hedera: By moving from a static 'trophy cabinet' to a metered CV, the dancer's identity becomes an active revenue stream. Micropayments handle the high-volume/low-value interactions of checking credentials during auditions or digital casting. Market: TAM $4.5B — Global dance education and professional talent scouting industries. | SAM $320M — Professional choreographers, athletic scouts, and digital avatar performers seeking verifiable work history. | SOM $12M — Web3-native dance communities and decentralized talent agencies on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KineticID" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-credentialing layer for motion. Pay 0.05 USDC to mint a verifiable 'Step-ID' claim (e.g., a specific flip, a battle win, or a choreography suite). Talent scouts and DAOs pay 0.01 USDC to query a dancer’s performance history, ensuring they are hiring based on cryptographically proven skill, not just social media clout. Every unlock of your professional profile funnels a micropayment back to the dancer. Discipline: Dance & Choreography (digital identity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a static 'trophy cabinet' to a metered CV, the dancer's identity becomes an active revenue stream. Micropayments handle the high-volume/low-value interactions of checking credentials during auditions or digital casting. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KineticID" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreoswap-marketplace-14-x402 Title: MOTIF · x402 Theme: Dance & Choreography (dance) · content exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn every eight-count into a revenue stream. MOTIF converts choreography clips into granular x402-gated assets. Dancers pay 0.01 USDC to instantly unlock the mirror-view of a motif, a specific floor-work sequence, or a transition sync. No subscriptions—just micro-payments for movement primitives to build your next set. Settlement is instant via HTS transfer, enabling creators to earn as their style is sampled. Why Hedera: Moving from a 'marketplace' to a 'metered access' model removes the friction of high-cost NFTs and replaces it with the high-velocity usage typical of professional rehearsal settings where dancers need specific inspiration, not ownership of a whole collection. Market: TAM $3.8B — The global digital creator economy for performing arts and social media content licensing. | SAM $420M — Professional choreographers, dance studios, and commercial music video production teams seeking licensed movement. | SOM $15M — Competitive urban and contemporary dance communities active on social media platforms (TikTok/Instagram) looking to monetize viral sequences. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MOTIF" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn every eight-count into a revenue stream. MOTIF converts choreography clips into granular x402-gated assets. Dancers pay 0.01 USDC to instantly unlock the mirror-view of a motif, a specific floor-work sequence, or a transition sync. No subscriptions—just micro-payments for movement primitives to build your next set. Settlement is instant via HTS transfer, enabling creators to earn as their style is sampled. Discipline: Dance & Choreography (content exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a 'marketplace' to a 'metered access' model removes the friction of high-cost NFTs and replaces it with the high-velocity usage typical of professional rehearsal settings where dancers need specific inspiration, not ownership of a whole collection. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MOTIF" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movemint-workshops-15-x402 Title: StepStream · x402 Theme: Dance & Choreography (dance) · tokenized education Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-move choreography drills where users pay 0.01 USDC to unlock the next 8-count of a masterclass. Dancers sign an HTS transfer packet to reveal pro-level tutorials frame-by-frame, eliminating high upfront course fees in favor of granular, metered learning. Why Hedera: Moving from high-friction NFT mints to low-friction x402 micropayments allows dancers to 'pay as they learn.' This micro-transaction model captures value from casual learners who won't commit to a $50 workshop but will spend $1.00 over 100 specific movement breakdowns. Market: TAM $5.2B — The global online vocational education and performing arts market. | SAM $450M — The digital dance & fitness subscription market transitioning to pay-per-session models. | SOM $12M — On-chain urban dance communities and global k-pop choreo-learners on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StepStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-move choreography drills where users pay 0.01 USDC to unlock the next 8-count of a masterclass. Dancers sign an HTS transfer packet to reveal pro-level tutorials frame-by-frame, eliminating high upfront course fees in favor of granular, metered learning. Discipline: Dance & Choreography (tokenized education). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from high-friction NFT mints to low-friction x402 micropayments allows dancers to 'pay as they learn.' This micro-transaction model captures value from casual learners who won't commit to a $50 workshop but will spend $1.00 over 100 specific movement breakdowns. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StepStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-stepchain-voting-16-x402 Title: StageCraft · x402 Theme: Dance & Choreography (dance) · event curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: StageCraft turns spectators into active judges through real-time, micro-incentivized curation. Instead of free, sybil-vulnerable polls, every ballot is a $0.01 USDC transaction authorized via the embedded wallet. These paid votes create high-integrity 'Proof of Hype' data, which distributes instant performance bonuses to dancers' wallets directly from the smart contract pool. Curation isn't just a survey; it's a metered contribution to the prize purse. Why Hedera: By moving from free voting to x402-native micropayments ($0.01), we eliminate bot manipulation and create a direct financial link between audience appreciation and performer compensation. The friction-less HTS transfer flow ensures high participation without traditional gas hurdles. Market: TAM $3.2B — Global live performance and event curation industry adopting 'pay-to-influence' engagement models across all performing arts. | SAM $450M — Focused on the growing 'Pro-Am' dance circuit, televised talent competitions, and ticketed underground battle scenes requiring transparent judging. | SOM $12M — Initial capture of regional street-dance leagues and university showcases seeking verifiable, crowd-sourced funding models. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageCraft" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT StageCraft turns spectators into active judges through real-time, micro-incentivized curation. Instead of free, sybil-vulnerable polls, every ballot is a $0.01 USDC transaction authorized via the embedded wallet. These paid votes create high-integrity 'Proof of Hype' data, which distributes instant performance bonuses to dancers' wallets directly from the smart contract pool. Curation isn't just a survey; it's a metered contribution to the prize purse. Discipline: Dance & Choreography (event curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from free voting to x402-native micropayments ($0.01), we eliminate bot manipulation and create a direct financial link between audience appreciation and performer compensation. The friction-less HTS transfer flow ensures high participation without traditional gas hurdles. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageCraft" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreoproof-licensing-17-x402 Title: 8COUNT · x402 Theme: Dance & Choreography (dance) · automated contracts Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A programmable kill-switch for movement. Secure a 0.01 USDC signature per performance loop or tutorial playback. Choreo-logic acts as a metered license: the moment the professional routine is accessed or the 'smart camera' verifies the sequence, a micro-settlement is triggered. No bulk licenses, just pay-per-step for digital dancers and AR training apps. Why Hedera: Replaces complex legal contracts with granular, metered access. By pricing at the 'sequence' level, it captures long-tail value from viral dance challenges and AI motion-capture training sets that would never sign a traditional license. Market: TAM $4.2B — The global digital rights management (DRM) and sports/dance licensing industry. | SAM $850M — The performance rights and UGC music-licensing market for short-form video. | SOM $12M — Base-native AR dance tutorials and motion-data API consumers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "8COUNT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A programmable kill-switch for movement. Secure a 0.01 USDC signature per performance loop or tutorial playback. Choreo-logic acts as a metered license: the moment the professional routine is accessed or the 'smart camera' verifies the sequence, a micro-settlement is triggered. No bulk licenses, just pay-per-step for digital dancers and AR training apps. Discipline: Dance & Choreography (automated contracts). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Replaces complex legal contracts with granular, metered access. By pricing at the 'sequence' level, it captures long-tail value from viral dance challenges and AI motion-capture training sets that would never sign a traditional license. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "8COUNT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dancetoken-staking-18-x402 Title: FloorWork · x402 Theme: Dance & Choreography (dance) · community incentives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Dancers post exclusive 15-second choreography loops that are obscured by a blur filter. Fans pay 0.01 USDC to 'Unlock the Move' via x402, instantly granting access to the high-res video and a downloadable breakdown. Each micropayment flows directly to the dancer’s the embedded wallet-linked wallet, creating a high-velocity feedback loop where the most popular routines earn the most in real-time. Why Hedera: Moves the model from passive 'staking' to active digital consumption. By making the payment per-view/per-unlock (0.01 USDC), it lowers the friction for fans to support multiple creators and turns choreography into a metered digital good. Market: TAM $4.5B — Global dance instruction and social video entertainment market. | SAM $180M — The digital specialized dance education and choreography marketplace. | SOM $12M — Early-adopter street dance and TikTok-centric choreographers leveraging x402 for direct-to-fan monetization on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FloorWork" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Dancers post exclusive 15-second choreography loops that are obscured by a blur filter. Fans pay 0.01 USDC to 'Unlock the Move' via x402, instantly granting access to the high-res video and a downloadable breakdown. Each micropayment flows directly to the dancer’s the embedded wallet-linked wallet, creating a high-velocity feedback loop where the most popular routines earn the most in real-time. Discipline: Dance & Choreography (community incentives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves the model from passive 'staking' to active digital consumption. By making the payment per-view/per-unlock (0.01 USDC), it lowers the friction for fans to support multiple creators and turns choreography into a metered digital good. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FloorWork" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-stepswap-royalties-19-x402 Title: Kinetic · x402 Theme: Dance & Choreography (dance) · resale tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Every time a dancer performs your routine for a ticketed event or social video, they sign an HTS transfer authorization to unlock the 'License to Perform' metadata. The facilitator settles the micro-royalty instantly on Hedera. No more chasing resale percentages; choreographers get paid per-view or per-stage-use via a sub-cent metered handshake. Why Hedera: Standard NFT royalties are easily bypassed on secondary markets. By shifting to a 'pay-per-usage' model via x402, the value is captured at the moment of execution/performance rather than just the moment of exchange. It turns choreography into a liquid, metered API for the body. Market: TAM $2.8B — The global performing arts licensing and intellectual property market. | SAM $450M — The digital creator economy for dance (TikTok creators, Fortnite emote developers, and commercial choreographers). | SOM $12M — Independent choreographers on Hedera seeking automated licensing for 'viral' challenges and studio workshops. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Every time a dancer performs your routine for a ticketed event or social video, they sign an HTS transfer authorization to unlock the 'License to Perform' metadata. The facilitator settles the micro-royalty instantly on Hedera. No more chasing resale percentages; choreographers get paid per-view or per-stage-use via a sub-cent metered handshake. Discipline: Dance & Choreography (resale tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Standard NFT royalties are easily bypassed on secondary markets. By shifting to a 'pay-per-usage' model via x402, the value is captured at the moment of execution/performance rather than just the moment of exchange. It turns choreography into a liquid, metered API for the body. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-danceledger-archives-20-x402 Title: Kinetic History · x402 Theme: Dance & Choreography (dance) · historical record Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity historical archive where every query and entry is a paid cryptographic commitment. Dancers and historians pay 0.01 USDC to mint a permanent ledger entry of a performance or to unlock rare motion-capture data from the archives. Payment is the proof of provenance, ensuring the ledger remains a clutter-free, high-value record of human movement. Why Hedera: By shifting from a free archive to a pay-per-call model, every piece of choreography has a cost to record and a cost to retrieve, turning the ledger into a self-sustaining digital library that survives beyond platform lifespans. Market: TAM $2.1B — Total addressable spend on global historical preservation and cultural heritage documentation. | SAM $450M — The digital dance archiving and choreography copyright market. | SOM $12M — Web3-native performance artists and historical preservation DAOs on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic History" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity historical archive where every query and entry is a paid cryptographic commitment. Dancers and historians pay 0.01 USDC to mint a permanent ledger entry of a performance or to unlock rare motion-capture data from the archives. Payment is the proof of provenance, ensuring the ledger remains a clutter-free, high-value record of human movement. Discipline: Dance & Choreography (historical record). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a free archive to a pay-per-call model, every piece of choreography has a cost to record and a cost to retrieve, turning the ledger into a self-sustaining digital library that survives beyond platform lifespans. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic History" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreominting-platform-21-x402 Title: StepSign · x402 Theme: Dance & Choreography (dance) · NFT creation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A motion-capture studio engine that converts dance sequences into unique NFT assets upon payment. Dancers pay 0.01 USDC per 'Record & Mint' call. The HTS transfer signature bridges the gap between physics and the ledger, instantly deploying a soulbound or tradable choreography token to Base as soon as the final pose is struck. no subscription, just pay for the capture. Why Hedera: By moving from a 'platform' model to a per-mint utility, dancers are freed from high gas costs and monthly fees. The 0.01 USDC price point democratizes dance preservation, turning every rehearsal into a potential asset creation event. Market: TAM $2.4B — The global dance education and NFT collectible market, increasingly driven by viral digital ownership and AI-generated avatars. | SAM $180M — Independent dancers and digital creators utilizing motion capture for social media, gaming, and VR assets. | SOM $4.5M — Early adopters in the 'Dance-to-Earn' and Web3 creative space on Hedera testnet using mobile-based AR capture. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StepSign" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A motion-capture studio engine that converts dance sequences into unique NFT assets upon payment. Dancers pay 0.01 USDC per 'Record & Mint' call. The HTS transfer signature bridges the gap between physics and the ledger, instantly deploying a soulbound or tradable choreography token to Base as soon as the final pose is struck. no subscription, just pay for the capture. Discipline: Dance & Choreography (NFT creation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a 'platform' model to a per-mint utility, dancers are freed from high gas costs and monthly fees. The 0.01 USDC price point democratizes dance preservation, turning every rehearsal into a potential asset creation event. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StepSign" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-tokenmove-sponsorship-22-x402 Title: VibeFlow · x402 Theme: Dance & Choreography (dance) · brand engagement Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Brands deploy 'Move-Pools' where every viral use of a signature dance move triggers a $0.01 micro-royalty to the choreographer via x402. Instead of clunky flat-fee legal contracts, influencers and fans 'Pay-to-Perform'—a micro-payment unlocks the high-res audio and licensed choreo-guide for content creation. Performance is metered: the more the move is replicated onchain, the more the brand refills the dancer's wallet in real-time. Why Hedera: Traditional dance sponsorships are manual and gatekept. x402 enables 'Proof-of-Step,' turning choreography into a metered digital asset where usage (unlocking the move/track) triggers instant, high-volume USDC settlement. Market: TAM $250B — The global influencer marketing and digital advertising economy as it shifts toward automated, performance-based agentic payment models. | SAM $1.2B — The aggregate annual creator-fund spend from brands like Nike, Red Bull, and Pepsi specifically targeting short-form dance content. | SOM $85M — Total choreographic licensing and 'challenge' sponsorship fees paid via Hedera and programmable micropayments. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VibeFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Brands deploy 'Move-Pools' where every viral use of a signature dance move triggers a $0.01 micro-royalty to the choreographer via x402. Instead of clunky flat-fee legal contracts, influencers and fans 'Pay-to-Perform'—a micro-payment unlocks the high-res audio and licensed choreo-guide for content creation. Performance is metered: the more the move is replicated onchain, the more the brand refills the dancer's wallet in real-time. Discipline: Dance & Choreography (brand engagement). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional dance sponsorships are manual and gatekept. x402 enables 'Proof-of-Step,' turning choreography into a metered digital asset where usage (unlocking the move/track) triggers instant, high-volume USDC settlement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VibeFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-crowddance-licensing-23-x402 Title: KINETIC · x402 Theme: Dance & Choreography (dance) · mass collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Crowdsource viral choreography through micro-incentives. Dancers submit 'moves' via signed HTS transfer permits; choreographers 'buy the bridge' by paying 0.01 USDC to unlock and sequence specific 5-second steps into a master routine. All rights auto-transfer upon micropayment settlement. Why Hedera: Moving from heavy legal licensing to high-velocity 'Step-as-a-Service'. By metering the acquisition of individual moves, a choreographer can assemble a high-budget routine for pennies while ensuring thousands of contributors receive instant, friction-free USDC settlements. Market: TAM $4.2B — The global dance instruction and intellectual property licensing economy. | SAM $180M — The digital dance & music synchronization market for social media creators and agencies. | SOM $12M — Web3-native choreographers and TikTok 'dance challenge' architects using Base for automated IP stacking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Crowdsource viral choreography through micro-incentives. Dancers submit 'moves' via signed HTS transfer permits; choreographers 'buy the bridge' by paying 0.01 USDC to unlock and sequence specific 5-second steps into a master routine. All rights auto-transfer upon micropayment settlement. Discipline: Dance & Choreography (mass collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from heavy legal licensing to high-velocity 'Step-as-a-Service'. By metering the acquisition of individual moves, a choreographer can assemble a high-budget routine for pennies while ensuring thousands of contributors receive instant, friction-free USDC settlements. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-danceswap-collaborations-24-x402 Title: StepStream · x402 Theme: Dance & Choreography (dance) · peer collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless choreography engine where every 8-count is a programmable asset. Dancers pay 0.01 USDC to 'step into' a shared sequence, adding their own layer or variation. The original choreographer receives a real-time micropayment for Every. Single. Iteration. Payment is the friction that ensures only high-signal movement enters the chain, creating a living, paid archive of human motion. x402 handles the atomic revenue split between the base layer creator and the collaborator at the moment of 'Commit'. Why Hedera: By turning choreography into a pay-per-frame/pay-per-step interaction, we solve the attribution problem in dance. Each contribution is a signed transaction, turning a collaborative session into a metered stream of professional creative data. Market: TAM $2.8B — The global 'Prosumer' creator economy and licensing market for digital movement. | SAM $450M — The digital dance education and TikTok/Reels creator monetization sector. | SOM $12M — Early adopter professional choreographers and motion-capture performers using Base for verifiable intellectual property. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StepStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless choreography engine where every 8-count is a programmable asset. Dancers pay 0.01 USDC to 'step into' a shared sequence, adding their own layer or variation. The original choreographer receives a real-time micropayment for Every. Single. Iteration. Payment is the friction that ensures only high-signal movement enters the chain, creating a living, paid archive of human motion. x402 handles the atomic revenue split between the base layer creator and the collaborator at the moment of 'Commit'. Discipline: Dance & Choreography (peer collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning choreography into a pay-per-frame/pay-per-step interaction, we solve the attribution problem in dance. Each contribution is a signed transaction, turning a collaborative session into a metered stream of professional creative data. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StepStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreo-sequence-vault-0-x402 Title: Sequence · x402 Theme: Dance & Choreography (dance) · dance notation archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-frame access to high-fidelity dance notation. Turn choreography into a liquid asset class where every 'read' of a Labanotation or Bennesh script triggers a micropayment to the creator. No subscriptions, just friction-less access to the world's premier movement library for students and AI animation trainers. Why Hedera: By billing per sequence access, the app transforms static archives into an active revenue stream. Professional choreographers protect their IP while allowing granular, affordable access for researchers without a paywall barrier. Market: TAM $2.1B — Global performing arts intellectual property and digital archive sector. | SAM $120M — The digital dance education and professional choreography licensing market. | SOM $8.5M — Dancers, conservatory students, and AI developers sourcing movement data for motion synthesis. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sequence" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-frame access to high-fidelity dance notation. Turn choreography into a liquid asset class where every 'read' of a Labanotation or Bennesh script triggers a micropayment to the creator. No subscriptions, just friction-less access to the world's premier movement library for students and AI animation trainers. Discipline: Dance & Choreography (dance notation archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By billing per sequence access, the app transforms static archives into an active revenue stream. Professional choreographers protect their IP while allowing granular, affordable access for researchers without a paywall barrier. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sequence" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movement-moodboard-1-x402 Title: Kinetic Tapestry · x402 Theme: Dance & Choreography (dance) · dance inspiration boards Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency choreography scaffold. Pay 0.01 USDC to finalize and pin a curated movement board to IPFS. Each 'Shift'—an atomic blend of spatial metadata and visual prompts—is cryptographically locked until the micro-transaction settles. Professional choreographers and TikTok creators pay per 'Save' to ensure their aesthetic signatures are permanented on-chain and ready for export to lighting rigs or AR mirrors. Why Hedera: By placing the pay-gate at the 'Save to IPFS' action, the app treats inspiration as a distinct, billable asset. It transforms passive browsing into active acquisition, monetizing the curation process directly. Market: TAM $12B — Global dance education, performance industry, and digital creator economy. | SAM $420M — Professional choreographers, competitive dance studios, and high-volume short-form video influencers. | SOM $1.8M — Early adopters in the 'Base' creative ecosystem and digital-native movement artists using spatial computing tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Tapestry" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency choreography scaffold. Pay 0.01 USDC to finalize and pin a curated movement board to IPFS. Each 'Shift'—an atomic blend of spatial metadata and visual prompts—is cryptographically locked until the micro-transaction settles. Professional choreographers and TikTok creators pay per 'Save' to ensure their aesthetic signatures are permanented on-chain and ready for export to lighting rigs or AR mirrors. Discipline: Dance & Choreography (dance inspiration boards). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By placing the pay-gate at the 'Save to IPFS' action, the app treats inspiration as a distinct, billable asset. It transforms passive browsing into active acquisition, monetizing the curation process directly. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Tapestry" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-nft-gallery-2-x402 Title: KINETIC · x402 Theme: Dance & Choreography (dance) · digital dance art Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity motion-capture canvas where choreographer sessions are encrypted at rest. Users pay 0.01 USDC to stream-decrypt a single 15-second loop of high-end movement data. Every play is a direct settlement to the performer's wallet, turning motion into a metered digital liquid. Why Hedera: Moves away from static ownership (NFTs) toward consumption-based fluidity. x402 allows for hyper-granular 'pay-per-view' movement without the friction of platform subscriptions or minting fees. Market: TAM $40B — The global dance, licensing, and digital performative arts sector. | SAM $120M — The digital creator economy for short-form video and 3D motion assets. | SOM $8.5M — High-end motion capture enthusiasts and digital art collectors on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity motion-capture canvas where choreographer sessions are encrypted at rest. Users pay 0.01 USDC to stream-decrypt a single 15-second loop of high-end movement data. Every play is a direct settlement to the performer's wallet, turning motion into a metered digital liquid. Discipline: Dance & Choreography (digital dance art). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves away from static ownership (NFTs) toward consumption-based fluidity. x402 allows for hyper-granular 'pay-per-view' movement without the friction of platform subscriptions or minting fees. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreographer-s-ledger-3-x402 Title: STANCE · x402 Theme: Dance & Choreography (dance) · IP rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-view licensing layer for dance. Choreographers store 8-bar move sequences as HTS transfer gates; dancers or creators pay 0.01 USDC to instantly unlock the canonical JSON instructional/usage rights for rehearsals or social media posting. Why Hedera: Moves IP rights from static registries to active micro-transactions. By metering the access to the 'source code' of a dance, it creates a high-velocity royalty stream where every tutorial view or rehearsal sync is a settlement event. Market: TAM $4.2B — Global dance instruction and digital performance rights market. | SAM $185M — Independent digital choreographers and 'Sway House' style content creators on Hedera. | SOM $1.2M — 120M micro-activations for trending dance challenges and tutorial unfolds. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STANCE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-view licensing layer for dance. Choreographers store 8-bar move sequences as HTS transfer gates; dancers or creators pay 0.01 USDC to instantly unlock the canonical JSON instructional/usage rights for rehearsals or social media posting. Discipline: Dance & Choreography (IP rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves IP rights from static registries to active micro-transactions. By metering the access to the 'source code' of a dance, it creates a high-velocity royalty stream where every tutorial view or rehearsal sync is a settlement event. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STANCE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-technique-archive-hub-4-x402 Title: ISO-KINETIC · x402 Theme: Dance & Choreography (dance) · dance training materials Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Step — pay-per-frame dance pedagogy. Users stream high-fidelity technique manuals and floor-work videos directly from IPFS, settled at 0.01 USDC per minute of playback or per PDF page viewed. It’s an unbundled masterclass where dancers pay only for the specific corrections they need, and choreographers earn instant, micro-residuals every time a student rehearses a sequence. No subscriptions, just friction-less movement study via the embedded wallet-signed HTS transfer calls. Why Hedera: Traditional dance platforms use clunky subscriptions that penalize casual learners. By metering access at the 'step' or 'page' level, we capture the high-frequency rehearsal market where dancers need to review a specific 30-second clip 100 times. IPFS ensures the IP is permanent; x402 ensures the creator is paid for every single loop. Market: TAM $3.8B — The global online fitness and performing arts instruction market moving toward micro-transactional access. | SAM $450M — The digital dance education and vocational training market adopting pay-as-you-go models. | SOM $12M — Professional contemporary and ballet conservatories using metered archives for student syllabus review on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ISO-KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Step — pay-per-frame dance pedagogy. Users stream high-fidelity technique manuals and floor-work videos directly from IPFS, settled at 0.01 USDC per minute of playback or per PDF page viewed. It’s an unbundled masterclass where dancers pay only for the specific corrections they need, and choreographers earn instant, micro-residuals every time a student rehearses a sequence. No subscriptions, just friction-less movement study via the embedded wallet-signed HTS transfer calls. Discipline: Dance & Choreography (dance training materials). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional dance platforms use clunky subscriptions that penalize casual learners. By metering access at the 'step' or 'page' level, we capture the high-frequency rehearsal market where dancers need to review a specific 30-second clip 100 times. IPFS ensures the IP is permanent; x402 ensures the creator is paid for every single loop. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ISO-KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movement-metadata-mapper-5-x402 Title: Kinetix · x402 Theme: Dance & Choreography (dance) · dance data annotation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Tag a frame. Precision choreography requires structured data. Input a CID, apply skeletal metadata, and sign the HTS transfer to pin the annotation. Pay-per-tag model enables rapid crowdsourcing of large-scale dance datasets for training autonomous motion models. Every mapping is a settled transaction on Hedera. Why Hedera: By transforming metadata entry into a micro-transaction, the platform incentivizes high-fidelity human labeling of movement—a prerequisite for AI dance synthesis. The x402 integration ensures the annotator is paid instantly and the data is verified via the transaction hash. Market: TAM $4.2B — The global AI training data market and computer vision sector for motion analysis. | SAM $140M — Professional choreographers, athletic researchers, and digital archivists using motion-capture data. | SOM $1.5M — Decentralized labeling teams and machine learning researchers training on Hedera testnet data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetix" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Tag a frame. Precision choreography requires structured data. Input a CID, apply skeletal metadata, and sign the HTS transfer to pin the annotation. Pay-per-tag model enables rapid crowdsourcing of large-scale dance datasets for training autonomous motion models. Every mapping is a settled transaction on Hedera. Discipline: Dance & Choreography (dance data annotation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By transforming metadata entry into a micro-transaction, the platform incentivizes high-fidelity human labeling of movement—a prerequisite for AI dance synthesis. The x402 integration ensures the annotator is paid instantly and the data is verified via the transaction hash. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetix" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-global-dance-archive-6-x402 Title: Kinetic Roots · x402 Theme: Dance & Choreography (dance) · dance heritage preservation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A liquid library of human movement where every frame of heritage is gated by a 0.01 USDC micro-settlement. Users sign via the embedded wallet to unlock high-fidelity performance archives, while the x402 primitive distributes sub-cents directly to the originating cultural conservancy or local estate. Pay-per-view preservation that turns a static archive into a streaming revenue stream for global dance history. Why Hedera: Transitioning from a static 'donation' model to a high-velocity 'metered access' model ensures the archive's longevity. By using x402, we remove the friction of subscriptions, allowing casual researchers and AI training models to pay only for the specific choreography data they consume. Market: TAM $1.4B — The global dance education and historical preservation sector. | SAM $180M — The digital cultural heritage and archival asset market. | SOM $12M — Specialized ethno-choreological data for academic research and VR/AR development. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Roots" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A liquid library of human movement where every frame of heritage is gated by a 0.01 USDC micro-settlement. Users sign via the embedded wallet to unlock high-fidelity performance archives, while the x402 primitive distributes sub-cents directly to the originating cultural conservancy or local estate. Pay-per-view preservation that turns a static archive into a streaming revenue stream for global dance history. Discipline: Dance & Choreography (dance heritage preservation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from a static 'donation' model to a high-velocity 'metered access' model ensures the archive's longevity. By using x402, we remove the friction of subscriptions, allowing casual researchers and AI training models to pay only for the specific choreography data they consume. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Roots" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-collaborative-choreo-file-7-x402 Title: SYNKRON · x402 Theme: Dance & Choreography (dance) · group choreography workflow Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for high-stakes dance crews. Pay 0.01 USDC to commit a sequence change, fork a formation, or pull the latest spatial JSON. Every modification is a micro-transaction, ensuring contributors are credited and versions are immutable. Pay-per-sync for live rehearsal terminals. Why Hedera: Choreography is often plagued by 'version hell' and uncredited contributions. By turning setiap sync and commit into a micro-payment, you professionalize the workflow and create a financial trail for intellectual property in movement. Market: TAM $1.2B — The global dance education and professional choreography market. | SAM $280M — Digital collaboration tools for competitive dance teams and theater productions. | SOM $4.5M — High-end competitive crews and professional stage choreographers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SYNKRON" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for high-stakes dance crews. Pay 0.01 USDC to commit a sequence change, fork a formation, or pull the latest spatial JSON. Every modification is a micro-transaction, ensuring contributors are credited and versions are immutable. Pay-per-sync for live rehearsal terminals. Discipline: Dance & Choreography (group choreography workflow). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Choreography is often plagued by 'version hell' and uncredited contributions. By turning setiap sync and commit into a micro-payment, you professionalize the workflow and create a financial trail for intellectual property in movement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SYNKRON" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-move-tokens-8-x402 Title: GLYPH · x402 Theme: Dance & Choreography (dance) · movement licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view movement library where dancers monetize their signature steps. Use x402 to unlock frame-by-frame 3D motion data or mirror-mode video tutorials for 0.01 USDC. Every time a student or creator loops a specific sequence for rehearsal, the original choreographer receives a micropayment settlement on Hedera. Movement isn't just shared; it's metered. Why Hedera: Traditional choreography is notoriously hard to monetize and protect. x402 turns every 'play' or 'view' of a movement sequence into a direct micro-license, allowing choreographers to earn from viral trends rather than just seeing them copied for free. Market: TAM $5.8B — The creator economy's total spend on digital assets, royalty-free content, and intellectual property. | SAM $450M — The global dance education and online tutorial market adopting micro-license models. | SOM $12M — High-growth urban dance creators and digital animators on Hedera seeking verified motion data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GLYPH" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view movement library where dancers monetize their signature steps. Use x402 to unlock frame-by-frame 3D motion data or mirror-mode video tutorials for 0.01 USDC. Every time a student or creator loops a specific sequence for rehearsal, the original choreographer receives a micropayment settlement on Hedera. Movement isn't just shared; it's metered. Discipline: Dance & Choreography (movement licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional choreography is notoriously hard to monetize and protect. x402 turns every 'play' or 'view' of a movement sequence into a direct micro-license, allowing choreographers to earn from viral trends rather than just seeing them copied for free. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GLYPH" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-virtual-rehearsal-logs-9-x402 Title: Footprint · x402 Theme: Dance & Choreography (dance) · practice session tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Archive a rehearsal state. Dancers trigger an x402 payment to commit practice logs, skeletal tracking data, and video timestamps to IPFS/Base. Coaches unlock private feedback streams per-session, and AI-choreographers pay to query the aggregate motion-data for generative sequence building. Payment is the literal 'save' button. Why Hedera: By turning every 'log' entry into a micro-transaction, the practice data gains financial weight and permanence. It shifts from a passive diary to a metered repository that agents can pay to access for style-transfer training. Market: TAM $5.2B — The global choreographer and performing arts market integrated with AI-motion training data. | SAM $420M — The digital dance education and professional training sector adopting performance-tracking tech. | SOM $18M — Independent choreographers and competitive dance studios utilizing pay-per-entry decentralized logs for portfolio verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Footprint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Archive a rehearsal state. Dancers trigger an x402 payment to commit practice logs, skeletal tracking data, and video timestamps to IPFS/Base. Coaches unlock private feedback streams per-session, and AI-choreographers pay to query the aggregate motion-data for generative sequence building. Payment is the literal 'save' button. Discipline: Dance & Choreography (practice session tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning every 'log' entry into a micro-transaction, the practice data gains financial weight and permanence. It shifts from a passive diary to a metered repository that agents can pay to access for style-transfer training. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Footprint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-interactive-dance-maps-10-x402 Title: Floorplan · x402 Theme: Dance & Choreography (dance) · movement spatialization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A global primitive for movement geometry. Pay 0.01 USDC to unlock a choreographer's spatial map (JSON) or publish your own onto the spatial ledger. Each 'step' is an on-chain asset, allowing dancers to purchase 'blocking' sequences or spatial patterns directly through their movement apps. Movement data is metered—pay only for the patterns you execute or download. Why Hedera: Moves dance from 'video imitation' to 'data consumption.' By making spatial maps x402-native, choreographers earn micro-royalties every time a dancer 'loads' a formation into their AR glasses or training suite, creating a high-velocity market for choreography data. Market: TAM $4.2B — The global creator economy for movement, including motion capture licensing and professional choreography rights. | SAM $850M — The digital fitness, AR gaming, and professional dance training market transitioning to data-driven standards. | SOM $12M — Early adopters in the contemporary dance and competitive cheer/drill circuit using on-chain spatial data for formation training. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Floorplan" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A global primitive for movement geometry. Pay 0.01 USDC to unlock a choreographer's spatial map (JSON) or publish your own onto the spatial ledger. Each 'step' is an on-chain asset, allowing dancers to purchase 'blocking' sequences or spatial patterns directly through their movement apps. Movement data is metered—pay only for the patterns you execute or download. Discipline: Dance & Choreography (movement spatialization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves dance from 'video imitation' to 'data consumption.' By making spatial maps x402-native, choreographers earn micro-royalties every time a dancer 'loads' a formation into their AR glasses or training suite, creating a high-velocity market for choreography data. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Floorplan" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-ai-dance-dataset-repository-11-x402 Title: STANCE · x402 Theme: Dance & Choreography (dance) · machine learning data Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Access high-fidelity motion capture sequences and skeletal JSON frames. Every request triggers a micro-settlement directly to the choreographer’s wallet, enabling a fair-trade marketplace where AI models 'pay to learn' posture and flow. API-first for training loops. Why Hedera: Shifts the model from a static repository to a metered data stream. By pricing at $0.01 per pose or sequence, you monetize the long-tail of training data and ensure dancers are compensated for the algorithmic replication of their style. Market: TAM $2.5B — The global AI training data and motion capture industry. | SAM $450M — The training data market for generative video and sports-science AI models. | SOM $12M — Niche creative-ML developers and animation studios requiring verified human-dance motion assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STANCE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Access high-fidelity motion capture sequences and skeletal JSON frames. Every request triggers a micro-settlement directly to the choreographer’s wallet, enabling a fair-trade marketplace where AI models 'pay to learn' posture and flow. API-first for training loops. Discipline: Dance & Choreography (machine learning data). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from a static repository to a metered data stream. By pricing at $0.01 per pose or sequence, you monetize the long-tail of training data and ensure dancers are compensated for the algorithmic replication of their style. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STANCE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-festival-archive-12-x402 Title: GLYPH · x402 Theme: Dance & Choreography (dance) · event documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-pull access to the festival's raw spatial data. Dance is ephemeral, but choreography shouldn't be. Performers upload high-fidelity motion captures and JSON session manifests. Fans and researchers pay a single micropayment to unlock a specific set, with the USDC streaming directly to the choreographer's wallet. No subscriptions, just a micro-fee for every digital ghost retrieved from the archive. Why Hedera: Moving from a static archive to a metered access protocol ensures creators are paid for the intellectual property of their movement. x402 allows for granular pricing—accessing a single photo vs. a full performance JSON—without the friction of credit cards or large transfers. Market: TAM $2.1B — The global event documentation and digital intellectual property market for performing arts. | SAM $180M — The dance education and digital performance research market, requiring authentic raw files for study. | SOM $12M — Specialized archival revenue for boutique international dance festivals and contemporary choreographers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GLYPH" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-pull access to the festival's raw spatial data. Dance is ephemeral, but choreography shouldn't be. Performers upload high-fidelity motion captures and JSON session manifests. Fans and researchers pay a single micropayment to unlock a specific set, with the USDC streaming directly to the choreographer's wallet. No subscriptions, just a micro-fee for every digital ghost retrieved from the archive. Discipline: Dance & Choreography (event documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a static archive to a metered access protocol ensures creators are paid for the intellectual property of their movement. x402 allows for granular pricing—accessing a single photo vs. a full performance JSON—without the friction of credit cards or large transfers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GLYPH" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-costume-design-catalog-13-x402 Title: ThreadCount · x402 Theme: Dance & Choreography (dance) · dance wardrobe archives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A programmatic garment repository where choreographers and designers pay-per-query. Access high-fidelity 3D patterns, textile JSON manifests, and historical production stills stored on IPFS. Payment is the key: 0.01 USDC unlocks a single manifest or a high-res design plate, enabling micro-licensing at the point of creative inspiration. Why Hedera: Current costume archives are gated behind university subscriptions or fragmented Pinterest boards. Moving to a pay-per-use model allows indie choreographers to access professional-grade design specs for a penny, while archives monetize long-tail assets without complex contracts. Market: TAM $2.4B — The global theatrical costume and dancewear rental/manufacturing industry moving toward digital twins. | SAM $185M — The digital design assets and stock photography market for performing arts and regional theater. | SOM $12M — Independent dance troupes and freelance costume designers using Base for decentralized portfolio access. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadCount" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A programmatic garment repository where choreographers and designers pay-per-query. Access high-fidelity 3D patterns, textile JSON manifests, and historical production stills stored on IPFS. Payment is the key: 0.01 USDC unlocks a single manifest or a high-res design plate, enabling micro-licensing at the point of creative inspiration. Discipline: Dance & Choreography (dance wardrobe archives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current costume archives are gated behind university subscriptions or fragmented Pinterest boards. Moving to a pay-per-use model allows indie choreographers to access professional-grade design specs for a penny, while archives monetize long-tail assets without complex contracts. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadCount" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movement-emotion-tags-14-x402 Title: SENTIC · x402 Theme: Dance & Choreography (dance) · affective annotation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A fractionalized emotional library for movement. Choreographers or AI agents pay $0.01 per query to retrieve HTS transfer signed metadata packets that map specific kinematic sequences to affective states. Every 'interpretive fetch' from IPFS triggers an instant USDC micropayment to the original dancer. Pay to decode the soul within the vector. Why Hedera: By gating the metadata via x402, movement becomes a liquid asset. This transforms simple tagging into a 'Pay-per-Inspiration' model where choreographers pay a penny to find the perfect 'melancholic' extension for their next piece, ensuring dancers are compensated for their specific expressive data. Market: TAM $450M - The global dance education and professional choreography software market, increasingly reliant on high-fidelity expressive metadata. | SAM $85M - The emerging market for motion capture datasets, VR animation assets, and AI-driven character rigging. | SOM $4.2M - Independent contemporary choreographers and experimental digital artists using Base for low-gas intellectual property licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SENTIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A fractionalized emotional library for movement. Choreographers or AI agents pay $0.01 per query to retrieve HTS transfer signed metadata packets that map specific kinematic sequences to affective states. Every 'interpretive fetch' from IPFS triggers an instant USDC micropayment to the original dancer. Pay to decode the soul within the vector. Discipline: Dance & Choreography (affective annotation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By gating the metadata via x402, movement becomes a liquid asset. This transforms simple tagging into a 'Pay-per-Inspiration' model where choreographers pay a penny to find the perfect 'melancholic' extension for their next piece, ensuring dancers are compensated for their specific expressive data. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SENTIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-challenge-repository-15-x402 Title: GhostStep · x402 Theme: Dance & Choreography (dance) · community choreography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered motion-library where every movement signature is a commodity. Use x402 to micro-lease choreography sequences. Dancers pay 0.01 USDC to unlock an IPFS-stored JSON 'ghost-frame' overlay or skeletal data for a specific challenge. Instead of a free repository, it's a high-velocity exchange where creators earn per-lesson, and AI pose-estimation nodes verify completion to unlock the next sequence in a global chain. Why Hedera: Turning choreography into granular data (JSON) makes it perfectly suited for micropayments. By charging per 'step' or 'unlock', you monetize the specific creative labor of the choreographer rather than relying on platform ad-revenue. x402 handles the high-volume, low-value nature of individual dance steps. Market: TAM $4.2B — The global digital dance education and TikTok/social creator economy moving toward sovereign content ownership. | SAM $14M — Dancers and social media creators using metered professional choreography tools rather than free tutorials. | SOM $850K — Hedera testnet early adopters participating in on-chain dance challenges and verified motion-data exchanges. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GhostStep" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered motion-library where every movement signature is a commodity. Use x402 to micro-lease choreography sequences. Dancers pay 0.01 USDC to unlock an IPFS-stored JSON 'ghost-frame' overlay or skeletal data for a specific challenge. Instead of a free repository, it's a high-velocity exchange where creators earn per-lesson, and AI pose-estimation nodes verify completion to unlock the next sequence in a global chain. Discipline: Dance & Choreography (community choreography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Turning choreography into granular data (JSON) makes it perfectly suited for micropayments. By charging per 'step' or 'unlock', you monetize the specific creative labor of the choreographer rather than relying on platform ad-revenue. x402 handles the high-volume, low-value nature of individual dance steps. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GhostStep" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movement-style-index-16-x402 Title: Kinetic Meter · x402 Theme: Dance & Choreography (dance) · genre classification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A peer-to-peer library of authenticated motion primitives. Users pay 0.01 USDC to unlock an HTS transfer signed machine-readable movement packet (JSON + IPFS CID). Choreographers earn per request, while AI models use the protocol to programmatically meter their training data ingestion. Every style classification is a verified micro-transaction on Hedera. Why Hedera: By turning the 'Index' into a metered gateway, the data becomes an asset rather than a static directory. x402 allows for granular 'pay-per-move' logic, which is essential for training high-fidelity movement LLMs or motion-capture agents without massive upfront licensing fees. Market: TAM $4.2B — The global motion capture and 3D animation data industry. | SAM $850M — The dance education and digital choreography tools market. | SOM $12M — Specialized AI training labs and procedural animation studios requiring verified genre datasets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Meter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A peer-to-peer library of authenticated motion primitives. Users pay 0.01 USDC to unlock an HTS transfer signed machine-readable movement packet (JSON + IPFS CID). Choreographers earn per request, while AI models use the protocol to programmatically meter their training data ingestion. Every style classification is a verified micro-transaction on Hedera. Discipline: Dance & Choreography (genre classification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the 'Index' into a metered gateway, the data becomes an asset rather than a static directory. x402 allows for granular 'pay-per-move' logic, which is essential for training high-fidelity movement LLMs or motion-capture agents without massive upfront licensing fees. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Meter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-live-choreo-snapshot-17-x402 Title: Kinetic Ledger · x402 Theme: Dance & Choreography (dance) · performance capture Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn live performance into digital provenance. 0.01 USDC triggers a high-fidelity motion capture frame, pinning the skeleton JSON and a visual snapshot to IPFS with an on-chain timestamp. Pay-per-capture ensures choreographers own the precise 'DNA' of a movement, creating a metered ledger of intellectual property for dancers and AI training sets. Why Hedera: By commoditizing the 'snapshot,' we turn a continuous stream into a series of intentional, paid micro-transactions. This creates a financial filter for quality and a friction-less IP registry for creators. Market: TAM $2.8B — Global motion capture and digital animation market expanding into decentralized creator economies. | SAM $450M — Performance IP and digital rights management for independent choreographers and dance studios. | SOM $12M — Early adopters in the 'Onchain Dance' scene using motion-capture for TikTok/Reels choreography attribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn live performance into digital provenance. 0.01 USDC triggers a high-fidelity motion capture frame, pinning the skeleton JSON and a visual snapshot to IPFS with an on-chain timestamp. Pay-per-capture ensures choreographers own the precise 'DNA' of a movement, creating a metered ledger of intellectual property for dancers and AI training sets. Discipline: Dance & Choreography (performance capture). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By commoditizing the 'snapshot,' we turn a continuous stream into a series of intentional, paid micro-transactions. This creates a financial filter for quality and a friction-less IP registry for creators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-studio-portfolio-18-x402 Title: Floorwork · x402 Theme: Dance & Choreography (dance) · business marketing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity choreographic asset vault where movement agencies and studios monetize their IP. Instead of a passive portfolio, it’s a 'Pay-to-Sync' platform where potential clients or franchise partners pay 0.01 USDC to unlock the Base-verified JSON metadata and IPFS CID for a specific choreography set, class manifest, or spatial blocking plan. Every unlock settles a micro-royalty instantly to the creator. Why Hedera: Traditional portfolios are static marketing expenses. By turning class manifests and choreography into x402-gated assets, the studio converts every inquiry into a micro-revenue event. This filters for serious commercial intent while building a verifiable on-chain 'Proof of Popularity' for their creative work. Market: TAM $3.2B — The global dance studio software and digital movement licensing market. | SAM $450M — The digital choreography and fitness certification licensing market. | SOM $12M — Professional dance studios and independent choreographers on Hedera seeking automated IP licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Floorwork" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity choreographic asset vault where movement agencies and studios monetize their IP. Instead of a passive portfolio, it’s a 'Pay-to-Sync' platform where potential clients or franchise partners pay 0.01 USDC to unlock the Base-verified JSON metadata and IPFS CID for a specific choreography set, class manifest, or spatial blocking plan. Every unlock settles a micro-royalty instantly to the creator. Discipline: Dance & Choreography (business marketing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional portfolios are static marketing expenses. By turning class manifests and choreography into x402-gated assets, the studio converts every inquiry into a micro-revenue event. This filters for serious commercial intent while building a verifiable on-chain 'Proof of Popularity' for their creative work. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Floorwork" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreography-remix-log-19-x402 Title: Kinetic Fork · x402 Theme: Dance & Choreography (dance) · creative versioning Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Transmit a cryptographic proof-of-lineage. Pay per 'Branch' to fork a movement sequence, settle the original creator's royalty on Hedera, and commit your remix JSON to IPFS. Payment is the atomic unit of creative attribution, ensuring the movement-genealogy is verifiable and the source choreographer is paid for every derivative work. Why Hedera: Creative versioning suffers from 'stolen moves' on social media. x402 turns a remix into a paid protocol event. By requiring a micropayment to link a new version to a parent manifest, the app creates a self-funding archive where credit is automated via the transaction hash. Market: TAM $2.8B — The global Creator Economy for short-form video and movement-based IP documentation. | SAM $450M — The digital choreography market and professional dance documentation space. | SOM $12M — Independent choreographers and viral dance creators on Hedera using micro-royalties to protect intellectual property. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Fork" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Transmit a cryptographic proof-of-lineage. Pay per 'Branch' to fork a movement sequence, settle the original creator's royalty on Hedera, and commit your remix JSON to IPFS. Payment is the atomic unit of creative attribution, ensuring the movement-genealogy is verifiable and the source choreographer is paid for every derivative work. Discipline: Dance & Choreography (creative versioning). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Creative versioning suffers from 'stolen moves' on social media. x402 turns a remix into a paid protocol event. By requiring a micropayment to link a new version to a parent manifest, the app creates a self-funding archive where credit is automated via the transaction hash. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Fork" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-props-inventory-20-x402 Title: StageStack · x402 Theme: Dance & Choreography (dance) · production resource management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-pull prop design specs. Access a global inventory of 3D-printable CAD files and digital twins for stage design via HTS transfer. Each micro-payment triggers an IPFS gateway unlock, allowing production managers to instant-license prop manifests without monthly subscriptions. Payment is the unlock for the production bible. Why Hedera: Current production workflows suffer from 'subscription fatigue' for asset libraries. By atomizing access at the item level (per-prop-sync), touring companies can pay for only what they pull, creating a high-velocity marketplace for theatrical assets. Market: TAM $3.2B — The global live performance production software and prop rental market transitioning to decentralized asset management. | SAM $450M — The shared economy for regional theaters, touring dance companies, and freelance scenic designers adopting web3 credentials. | SOM $12M — Professional dance troupes and digital stage asset creators on Hedera within year one. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageStack" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-pull prop design specs. Access a global inventory of 3D-printable CAD files and digital twins for stage design via HTS transfer. Each micro-payment triggers an IPFS gateway unlock, allowing production managers to instant-license prop manifests without monthly subscriptions. Payment is the unlock for the production bible. Discipline: Dance & Choreography (production resource management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current production workflows suffer from 'subscription fatigue' for asset libraries. By atomizing access at the item level (per-prop-sync), touring companies can pay for only what they pull, creating a high-velocity marketplace for theatrical assets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageStack" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movement-annotation-tool-21-x402 Title: Kinetic Ledger · x402 Theme: Dance & Choreography (dance) · dance research Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-frame. A decentralized dance cipher for researchers. Pay to fetch a motion-primitive (IPFS + JSON metadata) or tip to unlock a choreographer's private biomechanical library. Every research citation triggers a micro-transaction, turning movement syntax into a programmable asset class. Why Hedera: Movement research is currently siloed in academia or proprietary studios. By atomizing choreography into pay-per-use data blocks, we enable an 'open-source movement' economy where choreographers earn real-time royalties for every frame of movement studied or reused in AI training. Market: TAM $850M — The global dance education and professional choreography market shifting toward digital documentation and IP protection. | SAM $45M — The high-end digital archiving, motion capture licensing, and dance notation software market. | SOM $1.2M — Independent researchers, performance artists, and biomechanical developers active on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-frame. A decentralized dance cipher for researchers. Pay to fetch a motion-primitive (IPFS + JSON metadata) or tip to unlock a choreographer's private biomechanical library. Every research citation triggers a micro-transaction, turning movement syntax into a programmable asset class. Discipline: Dance & Choreography (dance research). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Movement research is currently siloed in academia or proprietary studios. By atomizing choreography into pay-per-use data blocks, we enable an 'open-source movement' economy where choreographers earn real-time royalties for every frame of movement studied or reused in AI training. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-therapy-logs-22-x402 Title: KINETIC · x402 Theme: Dance & Choreography (dance) · health documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-entry clinical ledger for movement therapy. Dancers and therapists pay 0.01 USDC to cryptographically seal and anchor recovery progress, Range of Motion (ROM) data, and session media to Base. No subscriptions—only pay for the history you document. Why Hedera: Health documentation often suffers from data silos or expensive SaaS overhead. x402 allows for a 'pay-as-you-heal' model where every session log is an atomic transaction, ensuring data integrity via HTS transfer without recurring fees. Market: TAM $4.5B — Global movement therapy and specialized sports medicine documentation market. | SAM $180M — Digital physical therapy and specialized EMR software for independent practitioners. | SOM $12M — Dance-specific clinics and injury rehab centers using performance-tracking tech via Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-entry clinical ledger for movement therapy. Dancers and therapists pay 0.01 USDC to cryptographically seal and anchor recovery progress, Range of Motion (ROM) data, and session media to Base. No subscriptions—only pay for the history you document. Discipline: Dance & Choreography (health documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Health documentation often suffers from data silos or expensive SaaS overhead. x402 allows for a 'pay-as-you-heal' model where every session log is an atomic transaction, ensuring data integrity via HTS transfer without recurring fees. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-interactive-step-manuals-23-x402 Title: 8COUNT · x402 Theme: Dance & Choreography (dance) · teaching aids Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for granular choreography. Students pay-per-frame to unlock high-fidelity motion-data overlays and JSON sequence metadata. Teachers monetize specific 'signature moves' rather than courses, allowing students to mix and match individual steps into local practice routines. Each frame-unlock triggers a 0.01 USDC settlement, instantly rewarding the creator for the specific technique consumed. Why Hedera: By moving away from monthly subscriptions to step-level micropayments, high-level choreographers can monetize their unique technical vocabulary without the overhead of full course production, while students only pay for the specific transitions they are struggling to master. Market: TAM $5.8B — The global dance education and professional training market moving toward remote-first, granular learning. | SAM $450M — The digital dance instruction and choreographer software market. | SOM $12M — Creators on Hedera utilizing pay-per-move monetization for technical drill libraries. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "8COUNT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for granular choreography. Students pay-per-frame to unlock high-fidelity motion-data overlays and JSON sequence metadata. Teachers monetize specific 'signature moves' rather than courses, allowing students to mix and match individual steps into local practice routines. Each frame-unlock triggers a 0.01 USDC settlement, instantly rewarding the creator for the specific technique consumed. Discipline: Dance & Choreography (teaching aids). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving away from monthly subscriptions to step-level micropayments, high-level choreographers can monetize their unique technical vocabulary without the overhead of full course production, while students only pay for the specific transitions they are struggling to master. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "8COUNT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movement-patent-registry-24-x402 Title: Kinetic Ledger · x402 Theme: Dance & Choreography (dance) · innovation tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency choreography oracle for the agentic era. Pay 0.01 USDC to cryptographically timestamp a motion-capture hash or movement manifest. Instead of slow legal filings, dancers execute 'Proof of Rhythm' via HTS transfer. Commercial users—from game studios to AI trainers—pay per look-up to verify origin, with micro-royalties streaming directly to the creator's Magic Link email sign-in. If a move goes viral, the registry acts as a metered clearinghouse for usage rights. Why Hedera: Innovation tracking in dance fails because IP law is too slow for TikTok trends. By making the 'patent' a low-friction 0.01 USDC event, we capture the high-volume, low-value interactions of social media choreography. x402 turns a static registry into a live, transactional attribution layer. Market: TAM $2.1B — The global Intellectual Property and digital rights management market for dance, performance arts, and motion-capture data. | SAM $450M — The digital goods and licensing market within 3D animation, social media marketing, and virtual avatar assets. | SOM $12M — The emerging economy of viral dance trend attribution and micro-licensing for short-form video creators and VR developers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency choreography oracle for the agentic era. Pay 0.01 USDC to cryptographically timestamp a motion-capture hash or movement manifest. Instead of slow legal filings, dancers execute 'Proof of Rhythm' via HTS transfer. Commercial users—from game studios to AI trainers—pay per look-up to verify origin, with micro-royalties streaming directly to the creator's Magic Link email sign-in. If a move goes viral, the registry acts as a metered clearinghouse for usage rights. Discipline: Dance & Choreography (innovation tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Innovation tracking in dance fails because IP law is too slow for TikTok trends. By making the 'patent' a low-friction 0.01 USDC event, we capture the high-volume, low-value interactions of social media choreography. x402 turns a static registry into a live, transactional attribution layer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-gasless-dance-battles-0-x402 Title: 8COUNT · x402 Theme: Dance & Choreography (dance) · competitive choreography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Choreographers post 'Origin' sequences. Challengers pay 0.01 USDC to submit a video response or a custom 8-count variation. The contract meters 'Judge-as-a-Service' AI evaluations or community voting, where each vote is a micro-transaction. Every entry fees the prize pool, and every critique fuels the evaluator. Micropayments turn spectators into active stakeholders in the battle's outcome. Why Hedera: By replacing 'gasless' with 'micro-paid,' the friction of high transaction costs is traded for a competitive stake. Paying 0.01 USDC to vote or enter creates a meritocratic economy where dancers are compensated in real-time for their technical skill, and the 'facilitator' model ensures the battle remains high-frequency and mobile-native. Market: TAM $1.4B — The worldwide dance industry, including digital content creation, professional instruction, and competitive leagues. | SAM $200M — Global competitive dance registry, high-school/college drill teams, and studio choreography marketplaces. | SOM $12M — On-chain urban dance communities and AI-assisted choreography feedback tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "8COUNT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Choreographers post 'Origin' sequences. Challengers pay 0.01 USDC to submit a video response or a custom 8-count variation. The contract meters 'Judge-as-a-Service' AI evaluations or community voting, where each vote is a micro-transaction. Every entry fees the prize pool, and every critique fuels the evaluator. Micropayments turn spectators into active stakeholders in the battle's outcome. Discipline: Dance & Choreography (competitive choreography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing 'gasless' with 'micro-paid,' the friction of high transaction costs is traded for a competitive stake. Paying 0.01 USDC to vote or enter creates a meritocratic economy where dancers are compensated in real-time for their technical skill, and the 'facilitator' model ensures the battle remains high-frequency and mobile-native. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "8COUNT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreo-nft-vault-1-x402 Title: MIRROR · x402 Theme: Dance & Choreography (dance) · digital choreography ownership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Dancers shouldn't hunt for gas to protect their flow. MIRROR converts body movement into high-fidelity motion data, locked by HTS transfer. Studios and creators pay 0.01 USDC to 'peek' a sequence or license a signature move for a social post. Every playback or export triggers an instant micropayment to the choreographer's Magic Link email sign-in. Ownership isn't a static token; it's a metered stream. Why Hedera: Shifts choreography from a 'stored asset' (NFT) to an 'access-controlled service' (x402). By making the cost to view or license a single sequence negligible (0.01 USDC), it reduces friction for viral usage while ensuring the creator is paid for every single interaction. Market: TAM $3.5B — The global dance education and intellectual property licensing market. | SAM $420M — The global digital creator economy for short-form video (TikTok/Reels) where choreography is the primary currency. | SOM $12M — Professional dance studios and viral trend-setters using Base to license 'Originator' status for signature moves. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MIRROR" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Dancers shouldn't hunt for gas to protect their flow. MIRROR converts body movement into high-fidelity motion data, locked by HTS transfer. Studios and creators pay 0.01 USDC to 'peek' a sequence or license a signature move for a social post. Every playback or export triggers an instant micropayment to the choreographer's Magic Link email sign-in. Ownership isn't a static token; it's a metered stream. Discipline: Dance & Choreography (digital choreography ownership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts choreography from a 'stored asset' (NFT) to an 'access-controlled service' (x402). By making the cost to view or license a single sequence negligible (0.01 USDC), it reduces friction for viral usage while ensuring the creator is paid for every single interaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MIRROR" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-studio-access-club-2-x402 Title: Floor · x402 Theme: Dance & Choreography (dance) · membership management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency floor-access protocol for dance studios. Replace clunky monthly subscriptions with per-minute or per-session streaming micropayments. Dancers sign HTS transfer permits via the embedded wallet to unlock smart-locks or check-in kiosks instantly. Studios eliminate credit card fees and 'ghost' members, while dancers only pay for the wood they actually use. Ideal for pop-up workshops and high-traffic urban rehearsals. Why Hedera: By shifting from 'token-gating' (all-or-nothing access) to 'micropayment-gating' (pay-per-use), we solve the friction of casual dance attendance. The x402 model allows for granular revenue sharing between the studio owner, the choreographer, and the DJ in real-time. Market: TAM $4.2B — The global dance studio and gym management software market, transitioning toward pay-as-you-go agent-driven access. | SAM $450M — Revenue from independent urban dance studios and specialized movement spaces adopting digital check-ins. | SOM $12M — Early adopters in the LA/London/NYC 'open class' scene utilizing Base for instant settlement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Floor" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency floor-access protocol for dance studios. Replace clunky monthly subscriptions with per-minute or per-session streaming micropayments. Dancers sign HTS transfer permits via the embedded wallet to unlock smart-locks or check-in kiosks instantly. Studios eliminate credit card fees and 'ghost' members, while dancers only pay for the wood they actually use. Ideal for pop-up workshops and high-traffic urban rehearsals. Discipline: Dance & Choreography (membership management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'token-gating' (all-or-nothing access) to 'micropayment-gating' (pay-per-use), we solve the friction of casual dance attendance. The x402 model allows for granular revenue sharing between the studio owner, the choreographer, and the DJ in real-time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Floor" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movement-royalty-tracker-3-x402 Title: KINETIC · x402 Theme: Dance & Choreography (dance) · usage rights Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Mirror movement via motion-capture API. A smart contract registry for choreographic atoms. When a creator or brand uses a signature sequence in a commercial video or digital avatar, the app validates the likeness and triggers a micropayment to the original choreographer. Pay-per-frame attestation for viral dance rights. Why Hedera: Traditional copyright fails choreography due to high legal friction. x402 converts legal 'rights' into technical 'access.' By metering the usage of a specific sequence, brands can programmatically clear rights for pennies per view, creating the first scalable royalty stream for movement designers. Market: TAM $2.8B — The global dance education and intellectual property market, including music video production and digital motion-capture licensing. | SAM $450M — The digital avatar and social media 'challenge' marketing sector requiring cleared movement assets. | SOM $12M — Independent choreographers and professional dancers licensing signature moves to early-adopter Web3 gaming and AR platforms. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Mirror movement via motion-capture API. A smart contract registry for choreographic atoms. When a creator or brand uses a signature sequence in a commercial video or digital avatar, the app validates the likeness and triggers a micropayment to the original choreographer. Pay-per-frame attestation for viral dance rights. Discipline: Dance & Choreography (usage rights). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional copyright fails choreography due to high legal friction. x402 converts legal 'rights' into technical 'access.' By metering the usage of a specific sequence, brands can programmatically clear rights for pennies per view, creating the first scalable royalty stream for movement designers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-gas-free-dance-voting-4-x402 Title: FlowState · x402 Theme: Dance & Choreography (dance) · community decision-making Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes choreography steering engine where every vote is a 0.01 USDC micro-stake. Unlike free polls prone to botting, this creates a 'skin-in-the-game' feedback loop for dance troupes. Dancers pay per vote to influence setlists, formation changes, or battle winners, with the accumulated pot streaming instantly to the winning choreographer via Base. Every choice is a signed primitive, ensuring community direction is authentic and financially backed. Why Hedera: Moving from 'gas-free' to 'micro-paid' transforms passive voting into active governance. The 0.01 USDC price point acts as a sybil-resistance filter while creating a direct monetization model for the creators being voted on. Market: TAM $2.4B — The global dance industry and digital fan-engagement market. | SAM $450M — The digital creator economy and competitive dance platform market. | SOM $12M — On-chain dance competitions, localized studio workshops, and reality-style choreography voting apps. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FlowState" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes choreography steering engine where every vote is a 0.01 USDC micro-stake. Unlike free polls prone to botting, this creates a 'skin-in-the-game' feedback loop for dance troupes. Dancers pay per vote to influence setlists, formation changes, or battle winners, with the accumulated pot streaming instantly to the winning choreographer via Base. Every choice is a signed primitive, ensuring community direction is authentic and financially backed. Discipline: Dance & Choreography (community decision-making). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'gas-free' to 'micro-paid' transforms passive voting into active governance. The 0.01 USDC price point acts as a sybil-resistance filter while creating a direct monetization model for the creators being voted on. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FlowState" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreo-collaboration-hub-5-x402 Title: SYNCED · x402 Theme: Dance & Choreography (dance) · joint choreography creation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Synchronous motion-banking for dance teams. Creators pay $0.01 per eight-count added to the global canvas, securing their IP rights on-chain. Pay-per-unlock allows dancers to buy the 'mirror-view' of a specific section or purchase a 60-second usage license for social media. No subscriptions, just micro-settlements for every step synced. Why Hedera: By turning choreography into a metered contribution model, every 'add' to the timeline is a micro-transaction that establishes provenance. It replaces the 'hub' with a 'ledger of movement' where the cost to participate filters for quality and immediate licensing. Market: TAM $2.8B — The global dance education and digital rights management market. | SAM $420M — The gig-economy for independent choreographers and commercial dance studios. | SOM $12M — Early adopters in the K-pop cover and competitive ballroom scene using x402 for remote routine building. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SYNCED" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Synchronous motion-banking for dance teams. Creators pay $0.01 per eight-count added to the global canvas, securing their IP rights on-chain. Pay-per-unlock allows dancers to buy the 'mirror-view' of a specific section or purchase a 60-second usage license for social media. No subscriptions, just micro-settlements for every step synced. Discipline: Dance & Choreography (joint choreography creation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning choreography into a metered contribution model, every 'add' to the timeline is a micro-transaction that establishes provenance. It replaces the 'hub' with a 'ledger of movement' where the cost to participate filters for quality and immediate licensing. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SYNCED" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-event-tickets-6-x402 Title: Footfall · x402 Theme: Dance & Choreography (dance) · ticketing system Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular ticketing layer where attendees pay 0.01 USDC per 'milestone' of a live performance. Instead of one-time entry, users stream access to the viewing area, premium masterclass segments, or exclusive backstage livestreams. High-frequency micro-validation ensures real-time revenue for performers while allowing fans to pay exactly for the duration they watch. Why Hedera: The frictionless HTS transfer signature allows for 'per-minute' or 'per-act' gatekeeping. This turns a static ticket into a dynamic, metered viewing experience, perfect for multi-stage festivals or long-form workshops. Market: TAM $7.2B — The global event ticketing and live-streaming industry transitioning to micro-access models. | SAM $450M — The digital-native niche of the global dance instruction and event streaming market. | SOM $12M — Early adopters in the urban dance and competitive choreography workshop circuits on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Footfall" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular ticketing layer where attendees pay 0.01 USDC per 'milestone' of a live performance. Instead of one-time entry, users stream access to the viewing area, premium masterclass segments, or exclusive backstage livestreams. High-frequency micro-validation ensures real-time revenue for performers while allowing fans to pay exactly for the duration they watch. Discipline: Dance & Choreography (ticketing system). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: The frictionless HTS transfer signature allows for 'per-minute' or 'per-act' gatekeeping. This turns a static ticket into a dynamic, metered viewing experience, perfect for multi-stage festivals or long-form workshops. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Footfall" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-gasless-dance-royalties-7-x402 Title: KINEMA · x402 Theme: Dance & Choreography (dance) · automatic payments Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A spatial-computing API that meters choreography usage. Instead of complex licensing, MR/AR apps pay 0.01 USDC per frame-data sequence streamed to a user's headset. Dancers sign their movement signatures once; every time a digital avatar replicates those steps in a game or social space, a micro-settlement is triggered via HTS transfer. Payment is the heartbeat of the routine. Why Hedera: Traditional royalty models fail at the 'micro-movement' level. x402 allows for granular, per-second billing of motion data, making it feasible to charge for single dance moves rather than entire performances. Market: TAM $5.4B — The global digital creator economy and animation middleware market. | SAM $280M — Choreography licensing within AR/VR gaming and social platforms (Metaverse avatars, Fortnite-style emotes). | SOM $12M — Independent choreographers on Hedera protecting movement IP through metered API distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINEMA" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A spatial-computing API that meters choreography usage. Instead of complex licensing, MR/AR apps pay 0.01 USDC per frame-data sequence streamed to a user's headset. Dancers sign their movement signatures once; every time a digital avatar replicates those steps in a game or social space, a micro-settlement is triggered via HTS transfer. Payment is the heartbeat of the routine. Discipline: Dance & Choreography (automatic payments). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional royalty models fail at the 'micro-movement' level. x402 allows for granular, per-second billing of motion data, making it feasible to charge for single dance moves rather than entire performances. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINEMA" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-move-provenance-8-x402 Title: Footprint · x402 Theme: Dance & Choreography (dance) · authenticity verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Sign, timestamp, and index the origin of a sequence. Dancers pay per attestation to secure their legacy; scouts and AI animators pay per query to verify the authentic creator of a viral move. Stop the theft of movement culture with micro-fees. Why Hedera: In the digital age, choreography is frequently stolen without credit. By shifting from 'gasless' to 'micro-paid,' we introduce a low-friction economic cost to claiming ownership, creating a high-integrity registry where every lookup and verification generates revenue for the protocol and the artist. Market: TAM $2.8B — The global dance and performance art market transitioning to digital licensing and NFT-gated choreography. | SAM $420M — The creator economy segment specifically focused on short-form video monetization and synchronization rights. | SOM $12M — The market for professional choreographers and motion-capture libraries requiring verifiable IP. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Footprint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Sign, timestamp, and index the origin of a sequence. Dancers pay per attestation to secure their legacy; scouts and AI animators pay per query to verify the authentic creator of a viral move. Stop the theft of movement culture with micro-fees. Discipline: Dance & Choreography (authenticity verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: In the digital age, choreography is frequently stolen without credit. By shifting from 'gasless' to 'micro-paid,' we introduce a low-friction economic cost to claiming ownership, creating a high-integrity registry where every lookup and verification generates revenue for the protocol and the artist. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Footprint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-gas-free-dance-workshops-9-x402 Title: 8COUNT · x402 Theme: Dance & Choreography (dance) · online learning Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-step choreography. Unlock individual 15-second instructional loops and professional feedback loops for $0.01 USDC. No subscriptions, no upfront course fees—just pay for the moves you actually learn. Each micro-payment triggers a signed proof of mastery, building an on-chain repertoire in real-time. Why Hedera: Shifts from a bulky 'course' model to a high-velocity 'micro-learning' model. x402 allows dancers to curate their own syllabus move-by-move, removing the friction of commitment while ensuring creators are paid for every individual view/instructional breakdown. Market: TAM $4.5B — The global online vocational training and performing arts market. | SAM $850M — The digital fitness and dance-tech sector adopting 'micropayment-per-view' monetization. | SOM $12M — Early adopters in the urban dance and TikTok choreography community seeking granular monetization of viral routines. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "8COUNT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-step choreography. Unlock individual 15-second instructional loops and professional feedback loops for $0.01 USDC. No subscriptions, no upfront course fees—just pay for the moves you actually learn. Each micro-payment triggers a signed proof of mastery, building an on-chain repertoire in real-time. Discipline: Dance & Choreography (online learning). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from a bulky 'course' model to a high-velocity 'micro-learning' model. x402 allows dancers to curate their own syllabus move-by-move, removing the friction of commitment while ensuring creators are paid for every individual view/instructional breakdown. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "8COUNT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreo-token-rewards-10-x402 Title: 8Count · x402 Theme: Dance & Choreography (dance) · creator incentives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered choreography engine where dancers pay $0.01 USDC to unlock the next 8-count of a viral routine. This eliminates 'freebooting' by turning dance steps into micro-assets. The choreographer receives instant settlement for every 'Learn Next' click, while the x402 wrapper handles the gasless signature via the embedded wallet. No subscriptions—just pay for the moves you actually practice. Why Hedera: Current creator platforms aggregate value at the top; x402 enables granular monetization of the movement itself. By making payment the trigger for content delivery, we turn choreography into a high-frequency micro-transaction utility. Market: TAM $5.8B — The global digital creator economy and online performing arts education market. | SAM $420M — Short-form video creators and professional dance educators moving toward direct-to-fan micro-monetization. | SOM $12M — Early adopters on Hedera/Farcaster utilizing Frame-based tutorials and 'pay-to-view' dance breakdowns. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "8Count" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered choreography engine where dancers pay $0.01 USDC to unlock the next 8-count of a viral routine. This eliminates 'freebooting' by turning dance steps into micro-assets. The choreographer receives instant settlement for every 'Learn Next' click, while the x402 wrapper handles the gasless signature via the embedded wallet. No subscriptions—just pay for the moves you actually practice. Discipline: Dance & Choreography (creator incentives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current creator platforms aggregate value at the top; x402 enables granular monetization of the movement itself. By making payment the trigger for content delivery, we turn choreography into a high-frequency micro-transaction utility. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "8Count" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-gear-marketplace-11-x402 Title: STANCE · x402 Theme: Dance & Choreography (dance) · digital commerce Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency commerce protocol where digital choreography patterns and outfit presets are metered. Every time a dancer 'rehearses' or previews a 3D wearable in an AR mirror, a 0.01 USDC micropayment settles to the creator. No storefront clutter—just pay-per-view patterns and pay-per-wear digital textures. Why Hedera: Shifts from high-friction NFT sales to low-friction utility. By charging 0.01 USDC per 'try-on' or 'step-unlock,' it captures value from browsing behavior, not just final sales. Market: TAM $4.2B — Global dance apparel and digital goods economy transitioning to smart-contract settlement. | SAM $140M — The digital apparel and asset market for social media creators and AR filters. | SOM $12M — Early adopters in the TikTok/Reels choreography space looking for micro-monetization of steps. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STANCE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency commerce protocol where digital choreography patterns and outfit presets are metered. Every time a dancer 'rehearses' or previews a 3D wearable in an AR mirror, a 0.01 USDC micropayment settles to the creator. No storefront clutter—just pay-per-view patterns and pay-per-wear digital textures. Discipline: Dance & Choreography (digital commerce). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from high-friction NFT sales to low-friction utility. By charging 0.01 USDC per 'try-on' or 'step-unlock,' it captures value from browsing behavior, not just final sales. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STANCE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movement-data-wallet-12-x402 Title: Kinétique · x402 Theme: Dance & Choreography (dance) · performance analytics Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Dancers monetize their kinesthetic IP through metered access. Developers or AI training models pay 0.01 USDC per frame of motion data (BVH/FBX) retrieved. Every playback, analysis, or 'ghost' overlay requires a micro-signature, turning performance metrics into a real-time revenue stream. Why Hedera: Existing models gate data behind massive subscriptions or exploit it for free. x402 allows a freelance dancer to charge per 'view' or 'download' of their specific sequence, creating a high-frequency, low-friction marketplace for motion capture. Market: TAM $15B — The global AI training data and spatial computing analytics market. | SAM $840M — The digital dance and motion capture asset market for gaming, animation, and fitness apps. | SOM $12M — Professional choreographers and motion capture performers on Hedera utilizing per-call monetization for data sets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinétique" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Dancers monetize their kinesthetic IP through metered access. Developers or AI training models pay 0.01 USDC per frame of motion data (BVH/FBX) retrieved. Every playback, analysis, or 'ghost' overlay requires a micro-signature, turning performance metrics into a real-time revenue stream. Discipline: Dance & Choreography (performance analytics). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Existing models gate data behind massive subscriptions or exploit it for free. x402 allows a freelance dancer to charge per 'view' or 'download' of their specific sequence, creating a high-frequency, low-friction marketplace for motion capture. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinétique" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-gasless-dance-challenges-13-x402 Title: StepStakes · x402 Theme: Dance & Choreography (dance) · social engagement Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn every dance battle into a micro-stakes arena. Users sign a 0.01 USDC x402 authorization to 'Drop' a challenge or 'Enter' a bracket. The micropayment stakes the entry, auto-unlocks the music license, and triggers the judge's AI scoring engine. No gas, just pure pay-to-play competition where the top 10% of scorers split the pool of entry pennies. Use your Magic Link email sign-in to sign—your move is your transaction. Why Hedera: By replacing 'free' with a $0.01 entry fee, we filter for quality and create an automated prize pool. The x402 mechanism handles the music royalty micro-distribution and the AI compute cost for pose estimation in a single, frictionless signature. Market: TAM $2.4B — The global social creator economy and casual mobile gaming market. | SAM $120M — Global digital dance competition market and viral creator challenges. | SOM $8.5M — High-frequency social dancers on Hedera using mobile-first embedded wallets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StepStakes" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn every dance battle into a micro-stakes arena. Users sign a 0.01 USDC x402 authorization to 'Drop' a challenge or 'Enter' a bracket. The micropayment stakes the entry, auto-unlocks the music license, and triggers the judge's AI scoring engine. No gas, just pure pay-to-play competition where the top 10% of scorers split the pool of entry pennies. Use your Magic Link email sign-in to sign—your move is your transaction. Discipline: Dance & Choreography (social engagement). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing 'free' with a $0.01 entry fee, we filter for quality and create an automated prize pool. The x402 mechanism handles the music royalty micro-distribution and the AI compute cost for pose estimation in a single, frictionless signature. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StepStakes" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-nft-gallery-14-x402 Title: VOGUE · x402 Theme: Dance & Choreography (dance) · exhibition curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-view choreography. A professional gallery for elite movement where every interaction is a metered performance. Instead of gas-heavy NFT minting, curators host encrypted high-fidelity sequences. Users pay per 'Move-Set' reveal or per minute of spatial viewing using HTS transfer. Choreographers earn instant USDC stream for every micro-session, turning viral trends into a high-margin, pay-gated exhibition economy. Why Hedera: By moving away from 'free' exploration to micropayments, the app creates a direct-to-creator revenue model for movement specialists. x402 eliminates the friction of gas while ensuring every eye on the canvas pays the artist. Market: TAM $2.4B — Global digital art exhibition and premium content streaming market. | SAM $180M — The digital dance rights and online dance education industry looking for non-subscription monetization. | SOM $12M — Web3-native performative artists and specialized choreography curators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VOGUE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-view choreography. A professional gallery for elite movement where every interaction is a metered performance. Instead of gas-heavy NFT minting, curators host encrypted high-fidelity sequences. Users pay per 'Move-Set' reveal or per minute of spatial viewing using HTS transfer. Choreographers earn instant USDC stream for every micro-session, turning viral trends into a high-margin, pay-gated exhibition economy. Discipline: Dance & Choreography (exhibition curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving away from 'free' exploration to micropayments, the app creates a direct-to-creator revenue model for movement specialists. x402 eliminates the friction of gas while ensuring every eye on the canvas pays the artist. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VOGUE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreography-licensing-hub-15-x402 Title: 8COUNT · x402 Theme: Dance & Choreography (dance) · rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Mirror and monetize. Dancers sign routine metadata to the chain; students and creators pay 0.01 USDC to unlock the high-definition breakdown or license the right to post a derivative video. Every 'copy-paste' of a trend triggers a micro-royalty flowing directly to the original choreographer's Magic Link email sign-in. Why Hedera: Rights management is currently broken by 'trend theft.' By turning every view or 'learn' action into a sub-penny transaction, we move from unenforceable copyright to a high-velocity utility model where creators are paid in real-time as their choreography goes viral. Market: TAM $4.2B — The global dance instruction and intellectual property licensing market. | SAM $140M — Projected licensing revenue within the short-form video (TikTok/Reels) creator economy. | SOM $8M — Initial capture of high-profile viral 'challenge' creators seeking to formalize ownership. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "8COUNT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Mirror and monetize. Dancers sign routine metadata to the chain; students and creators pay 0.01 USDC to unlock the high-definition breakdown or license the right to post a derivative video. Every 'copy-paste' of a trend triggers a micro-royalty flowing directly to the original choreographer's Magic Link email sign-in. Discipline: Dance & Choreography (rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Rights management is currently broken by 'trend theft.' By turning every view or 'learn' action into a sub-penny transaction, we move from unenforceable copyright to a high-velocity utility model where creators are paid in real-time as their choreography goes viral. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "8COUNT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-gas-free-dance-nfts-16-x402 Title: 8COUNT · x402 Theme: Dance & Choreography (dance) · blockchain collectibles Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A choreographic sequence library where every 'Step' is unlocked by a 0.01 USDC micropayment. Dancers record 8-count signature movements; fans pay to view the full sequence, and fellow creators pay to license the motion data for their own digital avatars. No gas-heavy minting—just frictionless, per-view revenue flowing directly to the artist's Magic Link email sign-in. Why Hedera: Traditional NFTs are lumpy and expensive. x402 turns dance into a metered utility where fans pay for 'instructional access' and AI-animation agents pay to 'ingest' professional human movement data one sequence at a time. Market: TAM $5.2B — The total creator economy segment for short-form video and motion-capture data. | SAM $420M — The global digital dance instruction and virtual avatar accessory market. | SOM $15M — Revenue from professional choreographers transitioning from ad-based social media to per-view micropayment models. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "8COUNT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A choreographic sequence library where every 'Step' is unlocked by a 0.01 USDC micropayment. Dancers record 8-count signature movements; fans pay to view the full sequence, and fellow creators pay to license the motion data for their own digital avatars. No gas-heavy minting—just frictionless, per-view revenue flowing directly to the artist's Magic Link email sign-in. Discipline: Dance & Choreography (blockchain collectibles). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFTs are lumpy and expensive. x402 turns dance into a metered utility where fans pay for 'instructional access' and AI-animation agents pay to 'ingest' professional human movement data one sequence at a time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "8COUNT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-virtual-dance-studios-17-x402 Title: LEAD · x402 Theme: Dance & Choreography (dance) · metaverse dance spaces Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An immersive spatial layer for Base where choreography is metered by the frame. Users pay 0.01 USDC to unlock studio access, mint a choreography sequence, or record a spatial performance. Every 'Save' or 'Sync' triggers an x402 payment, ensuring instructors and asset creators receive instant, atomic settlement for their spatial IP. Why Hedera: By shifting from 'token gating' to 'pay-per-action,' the studio transforms from a static space into a high-frequency economy. Dancers pay for the specific time and tools they use, rather than a fixed entry fee. Market: TAM $2.8B — Global virtual goods and metaverse services market, specifically targeting the 'creator-centric' dance economy. | SAM $450M — The emerging market for spatial computing assets and virtual performance goods within the Base ecosystem. | SOM $12M — High-frequency spatial recording and choreography minting fees for early adopters on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LEAD" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An immersive spatial layer for Base where choreography is metered by the frame. Users pay 0.01 USDC to unlock studio access, mint a choreography sequence, or record a spatial performance. Every 'Save' or 'Sync' triggers an x402 payment, ensuring instructors and asset creators receive instant, atomic settlement for their spatial IP. Discipline: Dance & Choreography (metaverse dance spaces). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'token gating' to 'pay-per-action,' the studio transforms from a static space into a high-frequency economy. Dancers pay for the specific time and tools they use, rather than a fixed entry fee. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LEAD" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-performance-ticket-nfts-18-x402 Title: GaitKeep · x402 Theme: Dance & Choreography (dance) · ticketing and access Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn live performances into metered micro-experiences. Instead of bulk tickets, fans use x402 to pay-per-view specific sequences or unlock front-row AR viewing angles in real-time. Use HTS transfer to authorize 0.01 USDC bursts that stream high-fidelity choreography metadata directly to the user's device, ensuring dancers are paid for every eyes-on-stage second. Why Hedera: Traditional NFT ticketing is static; x402 allows for granular, consumption-based monetization of movement data and live access, eliminating the friction of pre-buying while ensuring immediate settlement for performers. Market: TAM $18B — Global dance performance and live event ticketing market shifting toward hybrid/digital access. | SAM $450M — The growing digital-native 'choreography-as-a-service' and virtual concert industry. | SOM $12M — Independent dance studios and boutique immersive theater troupes using Base for low-cost fan engagement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GaitKeep" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn live performances into metered micro-experiences. Instead of bulk tickets, fans use x402 to pay-per-view specific sequences or unlock front-row AR viewing angles in real-time. Use HTS transfer to authorize 0.01 USDC bursts that stream high-fidelity choreography metadata directly to the user's device, ensuring dancers are paid for every eyes-on-stage second. Discipline: Dance & Choreography (ticketing and access). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT ticketing is static; x402 allows for granular, consumption-based monetization of movement data and live access, eliminating the friction of pre-buying while ensuring immediate settlement for performers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GaitKeep" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-gasless-dance-socials-19-x402 Title: 8COUNT · x402 Theme: Dance & Choreography (dance) · community building Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A peer-to-peer choreography marketplace where dancers pay a 0.01 USDC micro-fee to unlock 'The Set' (a 15-second lesson) or challenge a creator. Instead of passive scrolling, every view is a verified support transaction. Creators earn instant liquidity for every 'tap-to-learn', turning viral moments into a metered revenue stream. No gas, no subscriptions, just pay-per-groove. Why Hedera: Traditional social platforms offer 'exposure' while extracting value. By making the 'unlock' the core interaction via x402, we shift the community from passive followers to micro-patrons. Dancers get paid for their IP (the steps) instantly, and the friction of gas is replaced by a sub-cent payment that feels like a 'like'. Market: TAM $2.8B — Global digital dance instruction and short-form video monetization market. | SAM $450M — The estimated creator economy spend for dance, fitness, and movement influencers seeking platform independence. | SOM $12M — The niche of professional choreographers and urban dance communities transitioning to gated premium tutorials. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "8COUNT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A peer-to-peer choreography marketplace where dancers pay a 0.01 USDC micro-fee to unlock 'The Set' (a 15-second lesson) or challenge a creator. Instead of passive scrolling, every view is a verified support transaction. Creators earn instant liquidity for every 'tap-to-learn', turning viral moments into a metered revenue stream. No gas, no subscriptions, just pay-per-groove. Discipline: Dance & Choreography (community building). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional social platforms offer 'exposure' while extracting value. By making the 'unlock' the core interaction via x402, we shift the community from passive followers to micro-patrons. Dancers get paid for their IP (the steps) instantly, and the friction of gas is replaced by a sub-cent payment that feels like a 'like'. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "8COUNT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreographer-profiles-20-x402 Title: PROMPTUP · x402 Theme: Dance & Choreography (dance) · professional branding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A meritocratic discovery engine for professional choreography. Users pay 0.01 USDC to unlock a choreographer's verified technical specifications, rate cards, and performance reels. Facilitators settle a micro-fee for every 'Endorsement' signed, turning career credibility into a high-frequency, peer-validated asset class. Why Hedera: By gating professional data behind a $0.01 transaction, we eliminate 'portfolio inflation' and ensure that every profile view or endorsement is backed by a financial commitment, creating a high-signal directory for talent scouts. Market: TAM $1.4B — The global talent recruitment and performance arts branding sector. | SAM $420M — Professional dancers, scouts, and studio owners conducting digital talent discovery. | SOM $12M — The agency-represented choreographer market in LA, NYC, and London transitioning to onchain resumes. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PROMPTUP" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A meritocratic discovery engine for professional choreography. Users pay 0.01 USDC to unlock a choreographer's verified technical specifications, rate cards, and performance reels. Facilitators settle a micro-fee for every 'Endorsement' signed, turning career credibility into a high-frequency, peer-validated asset class. Discipline: Dance & Choreography (professional branding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By gating professional data behind a $0.01 transaction, we eliminate 'portfolio inflation' and ensure that every profile view or endorsement is backed by a financial commitment, creating a high-signal directory for talent scouts. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PROMPTUP" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dance-nft-streaming-21-x402 Title: 8COUNT · x402 Theme: Dance & Choreography (dance) · content distribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: 0.01 USDC — Pay-per-frame choreography. A pay-to-stream engine where dancers monetize content via high-frequency micropayments. Users pay per 30-second instructional segment or per exclusive performance unlock. By turning content consumption into a stream of HTS transfer signatures, creators bypass subscription churn and capture value for every second watched. Powered by the embedded wallet for instant, seedless sign-and-pay. Why Hedera: Traditional NFT streaming suffers from binary access (you own it or you don't). x402 enables 'metered movement,' allowing fans to pay exactly for what they watch, while dancers earn instantaneous, friction-free USDC for every view. Market: TAM $40B — The global digital video streaming and choreography education market. | SAM $2.4B — The social commerce and creator monetization market within the dance/performance sector. | SOM $12M — The early-adopter 'web3-native' dance community and AI-driven choreography platforms requiring metered API access for training data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "8COUNT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT 0.01 USDC — Pay-per-frame choreography. A pay-to-stream engine where dancers monetize content via high-frequency micropayments. Users pay per 30-second instructional segment or per exclusive performance unlock. By turning content consumption into a stream of HTS transfer signatures, creators bypass subscription churn and capture value for every second watched. Powered by the embedded wallet for instant, seedless sign-and-pay. Discipline: Dance & Choreography (content distribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT streaming suffers from binary access (you own it or you don't). x402 enables 'metered movement,' allowing fans to pay exactly for what they watch, while dancers earn instantaneous, friction-free USDC for every view. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "8COUNT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movement-token-tips-22-x402 Title: VibeCheck · x402 Theme: Dance & Choreography (dance) · fan support Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity choreographer portal where fans pay 0.01 USDC per 15-second 'Highlight Unlock.' Instead of static tipping, movement is metered; every view of a rare sequence or BTS rehearsal frame triggers an instant HTS transfer transfer. Dancers earn per-pixel, per-view, settling directly to their Magic Link email sign-in without gas friction. Why Hedera: Shifts the model from voluntary 'tipping' (low retention) to 'metered access' (high frequency). x402 allows fans to pay for the specific duration of movement they consume, turning choreography into a streaming asset. Market: TAM $4.5B — The global digital fan-support and creator tipping market. | SAM $140M — The choreographer and dance influencer economy moving toward direct-to-fan monetization. | SOM $12M — Web3-native dance communities and 'Challenge' participants on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VibeCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity choreographer portal where fans pay 0.01 USDC per 15-second 'Highlight Unlock.' Instead of static tipping, movement is metered; every view of a rare sequence or BTS rehearsal frame triggers an instant HTS transfer transfer. Dancers earn per-pixel, per-view, settling directly to their Magic Link email sign-in without gas friction. Discipline: Dance & Choreography (fan support). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from voluntary 'tipping' (low retention) to 'metered access' (high frequency). x402 allows fans to pay for the specific duration of movement they consume, turning choreography into a streaming asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VibeCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-gasless-dance-analytics-23-x402 Title: Kinetica · x402 Theme: Dance & Choreography (dance) · performance insights Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A professional-grade dance telemetry agent. Dancers and choreographers pay 0.01 USDC per frame or sequence analyzed for limb alignment, center-of-gravity drift, and sync-rate metrics. Instead of bulky monthly subscriptions, pay only for the choreography you refine. Settlement is instant via HTS transfer, enabling automated royalties for the creators of the original 'reference' routines. Why Hedera: Performance analytics is often high-computation. By charging 0.01 USDC per analysis call, the platform avoids the 'free-tier' overhead while allowing dancers to scale costs exactly to their rehearsal volume. The micropayment model also allows for automated revenue sharing with the original choreographers whose styles are being 'learned' or compared against. Market: TAM $3.5B — Digital fitness, AI-coaching, and decentralized talent scouting markets. | SAM $180M — Competitive dancers and independent choreographers globally. | SOM $12M — Early adopters in the urban dance and ballroom circuits using mobile-centric CV tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetica" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A professional-grade dance telemetry agent. Dancers and choreographers pay 0.01 USDC per frame or sequence analyzed for limb alignment, center-of-gravity drift, and sync-rate metrics. Instead of bulky monthly subscriptions, pay only for the choreography you refine. Settlement is instant via HTS transfer, enabling automated royalties for the creators of the original 'reference' routines. Discipline: Dance & Choreography (performance insights). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Performance analytics is often high-computation. By charging 0.01 USDC per analysis call, the platform avoids the 'free-tier' overhead while allowing dancers to scale costs exactly to their rehearsal volume. The micropayment model also allows for automated revenue sharing with the original choreographers whose styles are being 'learned' or compared against. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetica" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-nft-dance-collectives-24-x402 Title: SYNCHRON · x402 Theme: Dance & Choreography (dance) · group ownership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A collaborative choreography deck where every 'count' or 'sequence' added to the group routine requires a 0.01 USDC micro-stake. When the collective routine is licensed or performed, the x402 settlement layer via HTS transfer distributes revenue back to contributors based on their per-step signature history. High-velocity creative coordination with zero-friction settlement. Why Hedera: By making the 'step' the unit of account, we replace vague 'group ownership' with verifiable, paid participation. x402 ensures that dancers are paid instantly when their specific sequences are accessed or taught, turning choreography into a metered digital asset. Market: TAM $2.8B — The creator economy segment for performance arts and intellectual property licensing. | SAM $450M — The global digital choreography and online dance education market transitioning to per-lesson micropayments. | SOM $12M — Web3-native dance crews and viral challenge creators on Hedera seeking granular revenue splits. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SYNCHRON" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A collaborative choreography deck where every 'count' or 'sequence' added to the group routine requires a 0.01 USDC micro-stake. When the collective routine is licensed or performed, the x402 settlement layer via HTS transfer distributes revenue back to contributors based on their per-step signature history. High-velocity creative coordination with zero-friction settlement. Discipline: Dance & Choreography (group ownership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making the 'step' the unit of account, we replace vague 'group ownership' with verifiable, paid participation. x402 ensures that dancers are paid instantly when their specific sequences are accessed or taught, turning choreography into a metered digital asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SYNCHRON" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreochain-ledger-0-x402 Title: KINETIC · x402 Theme: Dance & Choreography (dance) · movement cataloging Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A movement-primitive library where every 'unlock' of a sequence instruction or high-fidelity motion capture file triggers a 0.01 USDC payment to the creator. Instead of static minting, it's a living library where AI animators and student dancers pay-per-view specific steps, ensuring choreographers are compensated for the 'usage' of their style, not just the ownership. Why Hedera: Shifts IP protection from 'static proof' to 'active monetization.' By metering access to movement data, choreographers capture value every time a move is referenced or practiced. Market: TAM $4.5B — The global animation and social media content creator economy. | SAM $180M — The digital choreography market, including TikTok creators, Fortnite emote designers, and professional dance educators. | SOM $12M — Web3-native choreographers and AI researchers training motion models via gated datasets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A movement-primitive library where every 'unlock' of a sequence instruction or high-fidelity motion capture file triggers a 0.01 USDC payment to the creator. Instead of static minting, it's a living library where AI animators and student dancers pay-per-view specific steps, ensuring choreographers are compensated for the 'usage' of their style, not just the ownership. Discipline: Dance & Choreography (movement cataloging). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts IP protection from 'static proof' to 'active monetization.' By metering access to movement data, choreographers capture value every time a move is referenced or practiced. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-flowmotion-archive-1-x402 Title: Glissade · x402 Theme: Dance & Choreography (dance) · dance notation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A digital library of Labanotation and movement scores where every 'step' is metered. Dancers and AI choreographers pay 0.01 USDC per frame or sequence to unlock high-fidelity playback and metadata. The x402 primitive replaces the clunky NFT purchase with a high-speed 'pay-per-move' model, allowing students to learn routines at a granular cost and creators to earn micro-royalties every time a specific sequence is accessed for practice. Why Hedera: Moving from static NFT ownership to metered access aligns with how dance is taught (repetition/segmentation). x402 settles the micro-payment instantly, allowing for fluid, uninterrupted practice sessions. Market: TAM $1.4B — The global dance education and professional choreography market transitioning to digital-first archives. | SAM $120M — Professional choreographers, dance conservatories, and commercial studios adopting digital notation. | SOM $8.5M — Early adopters in the contemporary and hip-hop communities using mobile-first notation tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Glissade" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A digital library of Labanotation and movement scores where every 'step' is metered. Dancers and AI choreographers pay 0.01 USDC per frame or sequence to unlock high-fidelity playback and metadata. The x402 primitive replaces the clunky NFT purchase with a high-speed 'pay-per-move' model, allowing students to learn routines at a granular cost and creators to earn micro-royalties every time a specific sequence is accessed for practice. Discipline: Dance & Choreography (dance notation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from static NFT ownership to metered access aligns with how dance is taught (repetition/segmentation). x402 settles the micro-payment instantly, allowing for fluid, uninterrupted practice sessions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Glissade" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-beatsync-provenance-2-x402 Title: PulseGate · x402 Theme: Dance & Choreography (dance) · rhythmic innovation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A rhythmic layer for the Base ecosystem. Pay 0.01 USDC to unlock an authenticated, high-fidelity beat pattern for your choreography. Every beat is a signed primitive; use it, loop it, or sync it. The x402 protocol ensures the original rhythmic innovator is paid per 'pulse' or 'unlock' by human dancers and AI motion-generators alike. Why Hedera: By shifting from high-friction NFT mints to low-friction pay-per-use pulses, choreographers can 'rent' rhythm for rehearsals or digital content without upfront licensing hurdles. This turns dance patterns into a liquid, metered utility. Market: TAM $3.8B — Global dance education and digital choreography royalty markets. | SAM $450M — Emerging on-chain creator economy and digital fashion/motion licensing markets. | SOM $12M — Web3 dance communities, TikTok-style rhythm challenges, and AI animation tool integrations. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PulseGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A rhythmic layer for the Base ecosystem. Pay 0.01 USDC to unlock an authenticated, high-fidelity beat pattern for your choreography. Every beat is a signed primitive; use it, loop it, or sync it. The x402 protocol ensures the original rhythmic innovator is paid per 'pulse' or 'unlock' by human dancers and AI motion-generators alike. Discipline: Dance & Choreography (rhythmic innovation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from high-friction NFT mints to low-friction pay-per-use pulses, choreographers can 'rent' rhythm for rehearsals or digital content without upfront licensing hurdles. This turns dance patterns into a liquid, metered utility. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PulseGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-posemint-studio-3-x402 Title: KineticFlow · x402 Theme: Dance & Choreography (dance) · pose collections Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless choreography library where every 'Save to Timeline' or 'Export Sequence' action triggers a 0.01 USDC x402 stream. Choreographers publish pose sets; developers and animators pay-per-snap to integrate signature movements into 3D environments. No subscriptions, just pure kinetic licensing at the granular level. Why Hedera: Traditional licensing is too clunky for micro-references. By pricing pose retrieval at $0.01 via HTS transfer, we enable animators to 'kitbash' dances while ensuring creators are paid instantly for every frame of inspiration used. Market: TAM $4.2B — The global creator economy for virtual identity, avatars, and digital expression. | SAM $850M — The digital animation and motion capture marketplace for game devs and social AR creators. | SOM $12M — Independent choreographers and indie game studios using Hedera testnet for low-friction asset licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KineticFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless choreography library where every 'Save to Timeline' or 'Export Sequence' action triggers a 0.01 USDC x402 stream. Choreographers publish pose sets; developers and animators pay-per-snap to integrate signature movements into 3D environments. No subscriptions, just pure kinetic licensing at the granular level. Discipline: Dance & Choreography (pose collections). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is too clunky for micro-references. By pricing pose retrieval at $0.01 via HTS transfer, we enable animators to 'kitbash' dances while ensuring creators are paid instantly for every frame of inspiration used. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KineticFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-steptrace-rights-4-x402 Title: Kinetic · x402 Theme: Dance & Choreography (dance) · step sequence rights Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for 'pay-per-move' learning. Choreographers lock high-value step sequences behind x402 gates. Dancers pay 0.01 USDC to unlock a single looped sequence for rehearsal or a motion-capture data stream for digital avatars. Settlement triggers an instant licensing rights receipt on Hedera. Why Hedera: Moves the value from static NFT ownership to active utility. Instead of buying a license upfront, creators earn every time a student loops a sequence or an AI dev scrapes a movement for an animation model. Market: TAM $14B — The global dance education market and the emerging AI-driven character animation industry. | SAM $1.2B — Professional dancers, collegiate programs, and commercial choreographers seeking micro-licensing. | SOM $85M — Viral creators on TikTok/Reels requiring verifiable 'Proof of Origin' for viral dance trends. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for 'pay-per-move' learning. Choreographers lock high-value step sequences behind x402 gates. Dancers pay 0.01 USDC to unlock a single looped sequence for rehearsal or a motion-capture data stream for digital avatars. Settlement triggers an instant licensing rights receipt on Hedera. Discipline: Dance & Choreography (step sequence rights). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves the value from static NFT ownership to active utility. Instead of buying a license upfront, creators earn every time a student loops a sequence or an AI dev scrapes a movement for an animation model. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-mirrormove-vault-5-x402 Title: Kinetic · x402 Theme: Dance & Choreography (dance) · movement replication Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A movement-primitive library where every 'Signature Step' is a metered asset. Dancers pay 0.01 USDC to unlock an AR ghost-overlay or skeleton-tracking data for a specific move. The facilitator settles the transaction instantly, enabling choreographers to monetize viral trends per-replication rather than per-platform-view. Payment is the unlock for the motion-data stream. Why Hedera: Traditional dance intellectual property is notoriously hard to enforce. By turning moves into pay-per-view movement primitives (HTS transfer), we create a granular market for choreography. Professional dancers and AI-driven animation agents become the paying users, securing the rights to replicate movement data via a low-friction micropayment. Market: TAM $2.8B — The global dance education, performance rights, and digital animation asset market. | SAM $420M — Professional choreographers, dance studios, and commercial motion-capture users. | SOM $18M — Early adopters in the viral social dance space and indie game developers purchasing movement assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A movement-primitive library where every 'Signature Step' is a metered asset. Dancers pay 0.01 USDC to unlock an AR ghost-overlay or skeleton-tracking data for a specific move. The facilitator settles the transaction instantly, enabling choreographers to monetize viral trends per-replication rather than per-platform-view. Payment is the unlock for the motion-data stream. Discipline: Dance & Choreography (movement replication). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional dance intellectual property is notoriously hard to enforce. By turning moves into pay-per-view movement primitives (HTS transfer), we create a granular market for choreography. Professional dancers and AI-driven animation agents become the paying users, securing the rights to replicate movement data via a low-friction micropayment. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-rhythmroots-nft-6-x402 Title: KineticHeritage · x402 Theme: Dance & Choreography (dance) · cultural dance preservation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered archive of rare cultural motion data. Pay 0.01 USDC to stream frame-by-frame skeletal data or high-fidelity tutorials. Authenticity is signed by the master practitioner, and every view micropays the original choreographer or their estate directly on-chain. Why Hedera: Traditional content platforms take 30-50%. x402 allows masters of niche cultural forms to monetize via grainular access—charging per step taught rather than a monthly sub, enabling 'pay-as-you-learn' global classrooms. Market: TAM $3.5B — Global online dance education and digital IP licensing market. | SAM $120M — Professional choreographers, dance students, and cultural researchers using digital pedagogy. | SOM $4M — Preservationists and students of endangered folk/indigenous dance forms. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KineticHeritage" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered archive of rare cultural motion data. Pay 0.01 USDC to stream frame-by-frame skeletal data or high-fidelity tutorials. Authenticity is signed by the master practitioner, and every view micropays the original choreographer or their estate directly on-chain. Discipline: Dance & Choreography (cultural dance preservation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional content platforms take 30-50%. x402 allows masters of niche cultural forms to monetize via grainular access—charging per step taught rather than a monthly sub, enabling 'pay-as-you-learn' global classrooms. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KineticHeritage" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-motionflow-tokens-7-x402 Title: Kinetic · x402 Theme: Dance & Choreography (dance) · dance flow sequences Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Unlock premium dance sequences frame-by-frame. Dancers pay 0.01 USDC to stream high-fidelity choreography transitions, securing the right to practice and perform specific 'flows'. Each micropayment acts as a proof-of-practice and a micro-royalty for the original choreographer, enabling a meter-based learning model where you only pay for the movements you study. Why Hedera: By shifting from lump-sum NFTs to x402 micropayments, we turn choreography into a liquid utility. It replaces the 'all-or-nothing' course model with a granular 'pay-as-you-flow' system, incentivizing creators to upload modular sequences while allowing dancers to build custom repertoires for pennies. Market: TAM $5.2B — The global dance studio and online fitness coaching industry. | SAM $450M — The digital dance instruction and choreographer royalty market. | SOM $12M — Web3-native urban dancers and movement artists using mobile-first training tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Unlock premium dance sequences frame-by-frame. Dancers pay 0.01 USDC to stream high-fidelity choreography transitions, securing the right to practice and perform specific 'flows'. Each micropayment acts as a proof-of-practice and a micro-royalty for the original choreographer, enabling a meter-based learning model where you only pay for the movements you study. Discipline: Dance & Choreography (dance flow sequences). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from lump-sum NFTs to x402 micropayments, we turn choreography into a liquid utility. It replaces the 'all-or-nothing' course model with a granular 'pay-as-you-flow' system, incentivizing creators to upload modular sequences while allowing dancers to build custom repertoires for pennies. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-echodance-ledger-8-x402 Title: Kinetic Sync · x402 Theme: Dance & Choreography (dance) · dance remix rights Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for motion-capture data and choreographic sequences where every 'loop' or 'remix' is metered. Dancers sign their routines; fans and other creators pay 0.01 USDC via HTS transfer to unlock the high-res playback or export the skeleton data for animation software. No lump sums—just pay-per-step remixing. Why Hedera: Traditional NFTs gate access behind high floor prices. x402 allows choreographers to monetize the *utility* of their movement. By pricing each 'reference' or 'remix' call at $0.01, it incentivizes high-volume viral usage while ensuring the original creator is settled on every instance of the dance being pulled into a new project. Market: TAM $4.2B — The global dance instruction and digital choreography market. | SAM $450M — Revenue generated by dance-centric social media creators and choreographic software users. | SOM $12M — On-chain creators and animators using motion data on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Sync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for motion-capture data and choreographic sequences where every 'loop' or 'remix' is metered. Dancers sign their routines; fans and other creators pay 0.01 USDC via HTS transfer to unlock the high-res playback or export the skeleton data for animation software. No lump sums—just pay-per-step remixing. Discipline: Dance & Choreography (dance remix rights). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFTs gate access behind high floor prices. x402 allows choreographers to monetize the *utility* of their movement. By pricing each 'reference' or 'remix' call at $0.01, it incentivizes high-volume viral usage while ensuring the original creator is settled on every instance of the dance being pulled into a new project. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Sync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-stepsync-collectibles-9-x402 Title: GhostRig · x402 Theme: Dance & Choreography (dance) · signature step NFTs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for 'Step-Streaming.' Dancers lock signature choreography behind 0.01 USDC x402 calls. Fans pay to unlock a looping 3D ghost-rig (AR overlay) of the move to practice in real-time. Each payment flows directly to the creator's wallet, turning a viral TikTok move into a metered revenue stream. Why Hedera: Current dance monetization relies on platform ad-rev or static NFTs. x402 enables 'pay-per-view' at the granular level of a single 8-count, making high-fidelity choreography instruction affordable for students and instantly liquid for dancers. Market: TAM $5.2B — The global creator economy and viral social media influence market. | SAM $450M — The digital dance instruction and choreographer royalty market. | SOM $12M — Early adopters in the 'Dance challenge' ecosystem and AR-based learning apps. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GhostRig" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for 'Step-Streaming.' Dancers lock signature choreography behind 0.01 USDC x402 calls. Fans pay to unlock a looping 3D ghost-rig (AR overlay) of the move to practice in real-time. Each payment flows directly to the creator's wallet, turning a viral TikTok move into a metered revenue stream. Discipline: Dance & Choreography (signature step NFTs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current dance monetization relies on platform ad-rev or static NFTs. x402 enables 'pay-per-view' at the granular level of a single 8-count, making high-fidelity choreography instruction affordable for students and instantly liquid for dancers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GhostRig" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreochain-badge-10-x402 Title: KINETIC · x402 Theme: Dance & Choreography (dance) · certified choreography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A sub-cent protocol for professional movement validation. Pay 0.01 USDC to cryptographically timestamp a sequence, verify a specific phrasing, or programmatically unlock high-fidelity instructional stems. Dancers pay to prove their repertoire; choreographers earn per verification call. Move beyond static badges to a live, metered ledger of certified physical skill. Why Hedera: Transitions from a one-time NFT 'badge' to a pay-per-verification utility. In a world of viral dance theft, fractional micropayments allow creators to charge for every 'legal' instructional access or official certification ping. Market: TAM $4.2B — The global dance industry, including commercial licensing and performance education. | SAM $180M — The digital dance instruction and online choreography marketplace. | SOM $9M — The high-end professional certification and copyright-protection layer for commercial choreographers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A sub-cent protocol for professional movement validation. Pay 0.01 USDC to cryptographically timestamp a sequence, verify a specific phrasing, or programmatically unlock high-fidelity instructional stems. Dancers pay to prove their repertoire; choreographers earn per verification call. Move beyond static badges to a live, metered ledger of certified physical skill. Discipline: Dance & Choreography (certified choreography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitions from a one-time NFT 'badge' to a pay-per-verification utility. In a world of viral dance theft, fractional micropayments allow creators to charge for every 'legal' instructional access or official certification ping. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dancedna-registry-11-x402 Title: Kinetic Signature · x402 Theme: Dance & Choreography (dance) · personal style encoding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Signature-move licensing for the viral era. Dancers record a sequence; our engine extracts the skeletal 'DNA' (kinematic metadata). Creators pay 0.01 USDC to unlock an 'Influence License' to use the style in a video or game, or tip 0.01 USDC to 'Reference' the originator in their metadata. No bulk subscriptions, just per-pose provenance. Why Hedera: By moving from lumpy NFT mints to 0.01 USDC micro-licenses, we enable high-velocity attribution. Every time an AI filter or an animator applies a dancer's specific 'DNA' to a rig, a micro-transaction settles on Hedera, turning movement into a metered digital asset. Market: TAM $2.4B — The global animation and gaming character-motion industry. | SAM $140M — The emerging market for digital dance assets, emote licensing, and AI motion-capture datasets. | SOM $8.5M — Individual creators and social media choreographers protecting and monetizing viral trends via micro-attributions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Signature" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Signature-move licensing for the viral era. Dancers record a sequence; our engine extracts the skeletal 'DNA' (kinematic metadata). Creators pay 0.01 USDC to unlock an 'Influence License' to use the style in a video or game, or tip 0.01 USDC to 'Reference' the originator in their metadata. No bulk subscriptions, just per-pose provenance. Discipline: Dance & Choreography (personal style encoding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from lumpy NFT mints to 0.01 USDC micro-licenses, we enable high-velocity attribution. Every time an AI filter or an animator applies a dancer's specific 'DNA' to a rig, a micro-transaction settles on Hedera, turning movement into a metered digital asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Signature" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-pulseproof-archives-12-x402 Title: Kinetic · x402 Theme: Dance & Choreography (dance) · historical dance archives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity motion database where every frame of archival dance is metered. Pay 0.01 USDC to stream a sequence, unlock a historical step breakdown, or license a choreography string for AI training. Move from 'ownership' to 'flow'—choreographers get paid in real-time as dancers session with the past. Why Hedera: Traditional archives are static; x402 turns them into a liquid library. By pricing access at the micro-level (per-view or per-step), it removes the friction of high-cost subscriptions for students while ensuring every 'unlock' of a legacy move directly compensates the estate or archival fund. Market: TAM $6.2B — The global heritage preservation and digital media licensing economy. | SAM $850M — The digital dance education and professional choreography software market. | SOM $45M — Web3-native performers, archival researchers, and AI motion-capture developers using Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity motion database where every frame of archival dance is metered. Pay 0.01 USDC to stream a sequence, unlock a historical step breakdown, or license a choreography string for AI training. Move from 'ownership' to 'flow'—choreographers get paid in real-time as dancers session with the past. Discipline: Dance & Choreography (historical dance archives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional archives are static; x402 turns them into a liquid library. By pricing access at the micro-level (per-view or per-step), it removes the friction of high-cost subscriptions for students while ensuring every 'unlock' of a legacy move directly compensates the estate or archival fund. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-spinmint-marketplace-13-x402 Title: SpinVault · x402 Theme: Dance & Choreography (dance) · 360 dance moves Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view choreography engine where users pay 0.01 USDC to unlock an immersive 360° orbital view of specialized dance maneuvers. Instead of high-friction NFT mints, dancers monetize via micro-settlements every time a student or creator previews a move for their digital avatar or practice session. Each signature spin is a gated instructional stream, settled instantly on Hedera. Why Hedera: Moves from the heavy 'ownership' model of NFTs to a fluid 'access' model. x402 allows for granular pricing—charging per 'look' or per 'frame'—turning viral moves into continuous micro-revenue streams for choreographers without requiring a 50 USDC purchase. Market: TAM $5.8B — Global dance education and digital motion capture markets. | SAM $420M — Professional choreographers, 3D animators, and VR/AR content creators seeking motion datasets. | SOM $12M — Early adopters in the Base and Farcaster ecosystems using 360 visuals for social content. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SpinVault" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view choreography engine where users pay 0.01 USDC to unlock an immersive 360° orbital view of specialized dance maneuvers. Instead of high-friction NFT mints, dancers monetize via micro-settlements every time a student or creator previews a move for their digital avatar or practice session. Each signature spin is a gated instructional stream, settled instantly on Hedera. Discipline: Dance & Choreography (360 dance moves). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves from the heavy 'ownership' model of NFTs to a fluid 'access' model. x402 allows for granular pricing—charging per 'look' or per 'frame'—turning viral moves into continuous micro-revenue streams for choreographers without requiring a 50 USDC purchase. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SpinVault" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movemark-licensing-14-x402 Title: StepFlow · x402 Theme: Dance & Choreography (dance) · choreography licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view choreography protocol where dancers pay $0.01 USDC to unlock tutorial frames or mirror a sequence. Each 'step' in a routine is metered via x402, ensuring choreographers are paid instantly for every rehearsal session or social media sync, replacing clunky upfront licenses with granular, per-use streaming royalties. Why Hedera: Choreography licensing is currently broken by manual outreach. By shifting to a pay-per-loop or pay-per-rehearsal model, creators capture value from the high-frequency 'practice' phase, not just the final performance. Fractionalizing the license into $0.01 micro-beats lowers the barrier for viral adoption while automating attribution. Market: TAM $4.2B — The global social media creator economy and professional performing arts licensing sector. | SAM $240M — The digital dance tutorial and professional choreography marketplace. | SOM $18M — Independent choreographers and dance influencers on Hedera using x402 to monetize viral 'challenge' sequences. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StepFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view choreography protocol where dancers pay $0.01 USDC to unlock tutorial frames or mirror a sequence. Each 'step' in a routine is metered via x402, ensuring choreographers are paid instantly for every rehearsal session or social media sync, replacing clunky upfront licenses with granular, per-use streaming royalties. Discipline: Dance & Choreography (choreography licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Choreography licensing is currently broken by manual outreach. By shifting to a pay-per-loop or pay-per-rehearsal model, creators capture value from the high-frequency 'practice' phase, not just the final performance. Fractionalizing the license into $0.01 micro-beats lowers the barrier for viral adoption while automating attribution. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StepFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-freezeframe-tokens-15-x402 Title: Statue · x402 Theme: Dance & Choreography (dance) · iconic frozen poses Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view 'Pose Library' for creators. Users pay 0.01 USDC to unlock high-fidelity, 3D-mapped dance silhouettes and 'Freeze Frames' from world-class choreographers. Use these captures as reference layers for digital art, animation rigs, or social media challenges. Every unlock settles instantly to the dancer's wallet. Why Hedera: Shifts the model from speculative NFT minting to a utility-based micropayment stream. By charging per access/unlock, it treats choreography as a functional asset for the creator economy rather than just a collectible. Market: TAM $45B — The global digital content creation and animation software market. | SAM $1.2B — Professional animators, TikTok creators, and digital illustrators requiring high-quality anatomical reference. | SOM $15M — Early adopters in the Farcaster and Base ecosystems looking for on-chain creative assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Statue" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view 'Pose Library' for creators. Users pay 0.01 USDC to unlock high-fidelity, 3D-mapped dance silhouettes and 'Freeze Frames' from world-class choreographers. Use these captures as reference layers for digital art, animation rigs, or social media challenges. Every unlock settles instantly to the dancer's wallet. Discipline: Dance & Choreography (iconic frozen poses). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from speculative NFT minting to a utility-based micropayment stream. By charging per access/unlock, it treats choreography as a functional asset for the creator economy rather than just a collectible. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Statue" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-stepsync-remix-16-x402 Title: Kinetik · x402 Theme: Dance & Choreography (dance) · dance step sampling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular motion-library for choreographers and AI-animators. Pay 0.01 USDC per step-sequence download to license keyframe data. x402 handles the micro-royalty for the original dancer instantly, facilitating real-time choreographic sampling and 'stacking' of movements for short-form video trends. Why Hedera: Transitioning from clunky NFT minting to pay-per-use sampling makes movement a liquid asset. x402 removes the friction of licensing, allowing a creator to pay only for the 2-second 'hook' of a dance rather than an entire collection. Market: TAM $4.2B — The global animation and motion capture software market. | SAM $850M — The digital-avatar skin and emote market, plus social media choreography licensing. | SOM $12M — Independent TikTok/Reel choreographers and game developers sourcing niche motion data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetik" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular motion-library for choreographers and AI-animators. Pay 0.01 USDC per step-sequence download to license keyframe data. x402 handles the micro-royalty for the original dancer instantly, facilitating real-time choreographic sampling and 'stacking' of movements for short-form video trends. Discipline: Dance & Choreography (dance step sampling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from clunky NFT minting to pay-per-use sampling makes movement a liquid asset. x402 removes the friction of licensing, allowing a creator to pay only for the 2-second 'hook' of a dance rather than an entire collection. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetik" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-choreocraft-tokens-17-x402 Title: STEPWISE · x402 Theme: Dance & Choreography (dance) · custom choreography kits Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-measure choreography streaming. Swap traditional upfront licenses for granular, per-step access. Teachers and studios pull specific sequences (warm-up, bridge, drop) from global creators via the x402 protocol. Every time a rehearsal video is unlocked or a beat-mapped notation is viewed, the creator is settled instantly. Eliminate the friction of $500 site-wide licenses by metering professional movement by the minute. Why Hedera: Current choreography sales are high-friction and high-cost. By breaking routines into pay-per-access nodes (e.g., $0.05 to unlock a 32-count block), you enable a massive long-tail of dance students to curate hybrid routines while ensuring creators are paid for every session. Market: TAM $2.8B — The global dance instruction and performing arts licensing market. | SAM $140M — Professional dance studios and independent freelance choreographers adopting digital licensing tools. | SOM $9M — Early-adopter creators on Hedera selling niche choreography kits to hybrid/virtual competitive dance teams. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEPWISE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-measure choreography streaming. Swap traditional upfront licenses for granular, per-step access. Teachers and studios pull specific sequences (warm-up, bridge, drop) from global creators via the x402 protocol. Every time a rehearsal video is unlocked or a beat-mapped notation is viewed, the creator is settled instantly. Eliminate the friction of $500 site-wide licenses by metering professional movement by the minute. Discipline: Dance & Choreography (custom choreography kits). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current choreography sales are high-friction and high-cost. By breaking routines into pay-per-access nodes (e.g., $0.05 to unlock a 32-count block), you enable a massive long-tail of dance students to curate hybrid routines while ensuring creators are paid for every session. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEPWISE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-dancetrail-provenance-18-x402 Title: Kinetic Seal · x402 Theme: Dance & Choreography (dance) · performance recording Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Every frame of movement is cryptographically anchored. Pay-per-save for high-fidelity spatial motion capture and authorship verification. Dancers pay 0.01 USDC to sign and seal movement sequences to the Base ledger, creating an immutable proof-of-originality for choreography before sharing it on social media. Prevents aesthetic theft by timestamping the 'Source Motion' at the moment of performance. Why Hedera: By making the verification process a micropayment primitive, the app treats 'choreographic proof' as a metered utility rather than a high-cost NFT mint, encouraging high-volume usage by professional performers. Market: TAM $2.8B — Global digital rights management and social media creator economy. | SAM $140M — Professional choreographers, session dancers, and studio instructors globally. | SOM $1.2M — Early-adopter dance influencers on Hedera and Farcaster protecting IP. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Seal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Every frame of movement is cryptographically anchored. Pay-per-save for high-fidelity spatial motion capture and authorship verification. Dancers pay 0.01 USDC to sign and seal movement sequences to the Base ledger, creating an immutable proof-of-originality for choreography before sharing it on social media. Prevents aesthetic theft by timestamping the 'Source Motion' at the moment of performance. Discipline: Dance & Choreography (performance recording). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making the verification process a micropayment primitive, the app treats 'choreographic proof' as a metered utility rather than a high-cost NFT mint, encouraging high-volume usage by professional performers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Seal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-rhythmripple-chain-19-x402 Title: PulseGate · x402 Theme: Dance & Choreography (dance) · beat choreography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered library of high-fidelity beat sequences where choreographers pay 0.01 USDC to unlock specific rhythm patterns for commercial use. Instead of high-friction licensing, dancers pay-per-measure to export 'Step-Codes'—machine-readable sequences that sync lighting, stage visuals, and music cues. Every repetition of a signature move in a digital performance triggers a micro-settlement to the original creator. Why Hedera: Current licensing is binary (all-or-nothing); this allows for granular, pay-per-move monetization of choreography, turning rhythm into a liquid API for the stage. Market: TAM $4.2B — The global dance industry, intellectual property licensing, and the growing market for motion-capture data. | SAM $210M — Professional choreographers, dance studios, and commercial music video producers transitioning to digital assets. | SOM $8.5M — Early adopters in the 'Tik-Tok' choreography space and NFT-meets-performance art communities on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PulseGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered library of high-fidelity beat sequences where choreographers pay 0.01 USDC to unlock specific rhythm patterns for commercial use. Instead of high-friction licensing, dancers pay-per-measure to export 'Step-Codes'—machine-readable sequences that sync lighting, stage visuals, and music cues. Every repetition of a signature move in a digital performance triggers a micro-settlement to the original creator. Discipline: Dance & Choreography (beat choreography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current licensing is binary (all-or-nothing); this allows for granular, pay-per-move monetization of choreography, turning rhythm into a liquid API for the stage. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PulseGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-movemint-studio-20-x402 Title: Kinetic · x402 Theme: Dance & Choreography (dance) · original move minting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A spatial registry for motion data. Choreographers publish motion-captured signature moves behind an x402 gate. Dancers and animators pay 0.01 USDC to unlock the high-fidelity rigging data or video tutorials for a single rehearsal session or social media use. Every 'learn' is a micro-settlement. Why Hedera: Moves the model from static NFT ownership to a 'pay-per-practice' utility. By leveraging HTS transfer, dancers can mirror a move in real-time without the friction of a heavy minting gas cost, turning choreography into a metered stream of professional IP. Market: TAM $5.2B — The global dance education and motion capture licensing industry. | SAM $450M — The digital assets market for animation, gaming, and social media content creators. | SOM $12M — Professional choreographers and urban dance influencers seeking direct monetization of viral trends on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A spatial registry for motion data. Choreographers publish motion-captured signature moves behind an x402 gate. Dancers and animators pay 0.01 USDC to unlock the high-fidelity rigging data or video tutorials for a single rehearsal session or social media use. Every 'learn' is a micro-settlement. Discipline: Dance & Choreography (original move minting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves the model from static NFT ownership to a 'pay-per-practice' utility. By leveraging HTS transfer, dancers can mirror a move in real-time without the friction of a heavy minting gas cost, turning choreography into a metered stream of professional IP. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-posechain-gallery-21-x402 Title: Kinetic · x402 Theme: Dance & Choreography (dance) · dance pose exhibitions Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity motion library where every pose is a discrete asset. Dancers capture unique 'stills' or signature frames; users pay 0.01 USDC to unlock the raw motion data (BVH/FBX) for animation, gaming, or metaverse avatars. Pay-per-pose creates a high-velocity marketplace for digital body language. Why Hedera: By atomizing dance into individual poses rather than full routines, the x402 model enables an 'asset store' experience where the cost of entry is negligible but the volume of micro-transactions scale with the needs of digital creators and AI animators. Market: TAM $2.1B — The global digital twin and character animation market. | SAM $450M — The 3D asset and motion capture library market for indie game devs and animators. | SOM $18M — Captured from competitive motion-data marketplaces via 0.01 USDC friction-free unlocks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity motion library where every pose is a discrete asset. Dancers capture unique 'stills' or signature frames; users pay 0.01 USDC to unlock the raw motion data (BVH/FBX) for animation, gaming, or metaverse avatars. Pay-per-pose creates a high-velocity marketplace for digital body language. Discipline: Dance & Choreography (dance pose exhibitions). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By atomizing dance into individual poses rather than full routines, the x402 model enables an 'asset store' experience where the cost of entry is negligible but the volume of micro-transactions scale with the needs of digital creators and AI animators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-glideproof-tokens-22-x402 Title: Kinetic Flow · x402 Theme: Dance & Choreography (dance) · fluidity measurement Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Quantify your kinetic flow. Sign with your Magic Link email sign-in to run real-time computer vision analysis on your dance upload. Each 'Glide Check' uses x402 to meter the GPU-intensive fluidity scoring, returning a Hedera transaction id that anchors your Flow State metrics on-chain. Ideal for digital scouting and tiered coaching access. Why Hedera: By turning fluidity analysis into a pay-per-frame or pay-per-upload utility, you remove high subscription barriers for dancers while ensuring the compute cost for CV (Computer Vision) is immediately settled. it transforms a passive NFT record into an active, metered diagnostic service. Market: TAM $1.2B — The global choreographer and AI-driven motion-capture analytics industry. | SAM $140M — The digital fitness and remote dance instruction market adopting automated feedback loops. | SOM $8.5M — Competitive breakdancers and contemporary performers seeking verifiable 'Flow Score' credentials for digital auditions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic Flow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Quantify your kinetic flow. Sign with your Magic Link email sign-in to run real-time computer vision analysis on your dance upload. Each 'Glide Check' uses x402 to meter the GPU-intensive fluidity scoring, returning a Hedera transaction id that anchors your Flow State metrics on-chain. Ideal for digital scouting and tiered coaching access. Discipline: Dance & Choreography (fluidity measurement). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning fluidity analysis into a pay-per-frame or pay-per-upload utility, you remove high subscription barriers for dancers while ensuring the compute cost for CV (Computer Vision) is immediately settled. it transforms a passive NFT record into an active, metered diagnostic service. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic Flow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-shadowstep-registry-23-x402 Title: ShadowStep · x402 Theme: Dance & Choreography (dance) · silent movement catalog Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular library of silent choreography where movement is a metered asset. Dancers upload 'ShadowSteps'—unique movement sequences and lip-sync patterns. Each time a creator, animator, or AI agent previews a sequence, they pay 0.01 USDC to unlock the motion data. High-fidelity motion curves are gated behind the pay-per-call primitive, ensuring choreography is treated as professional IP from the first frame. Why Hedera: By shifting from lumpy NFT licensing to x402 micropayments, choreographers monetize the 'browsing' and 'sampling' phase of creative direction. It turns a static registry into a live, metered API for movement. Market: TAM $2.8B — The global animation, VFX, and digital rights management industry. | SAM $450M — The digital choreography and motion capture market for gaming and social media content creators. | SOM $12M — Professional dancers and virtual influencers on Hedera seeking automated, fair-use compensation for viral trends. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ShadowStep" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular library of silent choreography where movement is a metered asset. Dancers upload 'ShadowSteps'—unique movement sequences and lip-sync patterns. Each time a creator, animator, or AI agent previews a sequence, they pay 0.01 USDC to unlock the motion data. High-fidelity motion curves are gated behind the pay-per-call primitive, ensuring choreography is treated as professional IP from the first frame. Discipline: Dance & Choreography (silent movement catalog). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from lumpy NFT licensing to x402 micropayments, choreographers monetize the 'browsing' and 'sampling' phase of creative direction. It turns a static registry into a live, metered API for movement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ShadowStep" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA dance-leapchain-ledger-24-x402 Title: Grand Jeté Auth · x402 Theme: Dance & Choreography (dance) · leaps and jumps Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A motion-capture validation engine for elite dancers. Pay 0.05 USDC per frame to cross-reference your leap's trajectory, hang-time, and extension against a verified signature database. Successful matches generate a unique, sub-cent proof-of-authenticity, allowing choreographers to charge micro-royalties every time their specific technique or sequence is 'called' in a commercial performance or AI-generated animation. Why Hedera: Moves from static NFT ownership to a functional, metered licensing engine. Payment facilitates the verification and the subsequent royalty stream, making the jump a programmable asset. Market: TAM $2.4B — The global dance instruction and digital motion licensing market, increasingly dominated by algorithmic content creation. | SAM $180M — Independent choreographers, social media creators, and motion-capture studios seeking verifiable technique assets. | SOM $14M — Early adopters in the competitive dance circuit and 'Dancespan' protocol users. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Grand Jeté Auth" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A motion-capture validation engine for elite dancers. Pay 0.05 USDC per frame to cross-reference your leap's trajectory, hang-time, and extension against a verified signature database. Successful matches generate a unique, sub-cent proof-of-authenticity, allowing choreographers to charge micro-royalties every time their specific technique or sequence is 'called' in a commercial performance or AI-generated animation. Discipline: Dance & Choreography (leaps and jumps). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves from static NFT ownership to a functional, metered licensing engine. Payment facilitates the verification and the subsequent royalty stream, making the jump a programmable asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Grand Jeté Auth" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ============================================================================== THEME · Fashion & Textile Design fashion designers, textile artists, costume designers, stylists ============================================================================== ------------------------------------------------------------------------------ IDEA fashion-fabrictrace-ledger-0-x402 Title: FiberProof · x402 Theme: Fashion & Textile Design (fashion) · material provenance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Micro-verify the origin of every thread. Designers pay 0.01 USDC to pull a real-time provenance certificate via HTS transfer, instantly unlocking a secure Hedera transaction id that validates organic claims for the consumer. Zero subscriptions—pay only per fabric roll audited. Why Hedera: Shifts provenance from a static database to a metered audit service. By turning verification into a micropayment, small indie labels can afford professional-grade supply chain transparency without upfront enterprise overhead. Market: TAM $2.8B — Global sustainable textile verification and supply chain transparency market. | SAM $420M — Sustainable luxury and mid-market labels utilizing on-chain supply chain tooling. | SOM $12M — Early-adopter boutique designers on Hedera requiring verifiable 'Organic' credentials for capsule collections. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FiberProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Micro-verify the origin of every thread. Designers pay 0.01 USDC to pull a real-time provenance certificate via HTS transfer, instantly unlocking a secure Hedera transaction id that validates organic claims for the consumer. Zero subscriptions—pay only per fabric roll audited. Discipline: Fashion & Textile Design (material provenance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts provenance from a static database to a metered audit service. By turning verification into a micropayment, small indie labels can afford professional-grade supply chain transparency without upfront enterprise overhead. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FiberProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-designauth-mint-1-x402 Title: PatternGuard · x402 Theme: Fashion & Textile Design (fashion) · design copyright Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-throughput timestamping layer for garment patterns and textile prints. Designers pay 0.01 USDC to instantly log a cryptographic fingerprint of their work to Base, generating a verifiable 'Proof of Creation' certificate. This replaces slow, expensive legal filing with a sub-cent digital notary that protects designs the moment they leave the iPad. Why Hedera: By turning copyright into a micropayment primitive, we eliminate the friction of traditional IP registration. Designers utilize a pay-per-sketch model that provides immediate cryptographic protection without recurring SaaS overhead. Market: TAM $40B — The global fashion intellectual property and licensing market. | SAM $1.2B — The total addressable market of digital fashion designers, small-batch manufacturers, and textile artists adopting blockchain-based provenance. | SOM $85M — Independent designers and design students protecting high-volume weekly output through micropayment friction. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PatternGuard" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-throughput timestamping layer for garment patterns and textile prints. Designers pay 0.01 USDC to instantly log a cryptographic fingerprint of their work to Base, generating a verifiable 'Proof of Creation' certificate. This replaces slow, expensive legal filing with a sub-cent digital notary that protects designs the moment they leave the iPad. Discipline: Fashion & Textile Design (design copyright). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning copyright into a micropayment primitive, we eliminate the friction of traditional IP registration. Designers utilize a pay-per-sketch model that provides immediate cryptographic protection without recurring SaaS overhead. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PatternGuard" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-colorpalette-dao-2-x402 Title: HUEBLOCK · x402 Theme: Fashion & Textile Design (fashion) · color curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity color library where every hex code curation is a metered asset. Designers pay 0.01 USDC to unlock palette sets or export CSS/Tailwind variables, with royalties streaming instantly to the original curators. No subscriptions—just micro-payments per palette pull. Why Hedera: Moves from a heavy DAO governance model to a granular, market-driven liquidity layer for aesthetics. x402 allows for 'pay-per-look' integration directly into Figma plugins or dev environments. Market: TAM $2.8B — Global digital design assets and stock color-system markets. | SAM $450M — On-chain fashion designers and UI/UX agencies using programmable design tokens. | SOM $12M — Freelance digital textile designers and generative art creators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "HUEBLOCK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity color library where every hex code curation is a metered asset. Designers pay 0.01 USDC to unlock palette sets or export CSS/Tailwind variables, with royalties streaming instantly to the original curators. No subscriptions—just micro-payments per palette pull. Discipline: Fashion & Textile Design (color curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves from a heavy DAO governance model to a granular, market-driven liquidity layer for aesthetics. x402 allows for 'pay-per-look' integration directly into Figma plugins or dev environments. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "HUEBLOCK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-virtualfitting-nft-3-x402 Title: Drape · x402 Theme: Fashion & Textile Design (fashion) · virtual try-on Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An ultra-fast, high-fidelity virtual try-on engine where users pay 0.01 USDC per garment overlay. Each session instant-renders a preview using x402 permissions, allowing brands to meter digital wardrobe access and designers to monetize pattern-fit testing at the granular level. Every successful fit returns a Hedera transaction id, serving as an immutable proof-of-wear for social curation. Why Hedera: Standardizing the 'cost per try-on' removes the friction of monthly subscriptions for casual shoppers while providing a high-volume revenue stream for digital tailors. It treats the rendering compute as a metered utility. Market: TAM $450B — Global e-commerce apparel market moving toward 3D-first retail interfaces. | SAM $1.4B — The projected market for AI-driven fashion technology and virtual fitting rooms. | SOM $18M — Captured from high-end digital fashion boutiques and independent 3D garment creators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Drape" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An ultra-fast, high-fidelity virtual try-on engine where users pay 0.01 USDC per garment overlay. Each session instant-renders a preview using x402 permissions, allowing brands to meter digital wardrobe access and designers to monetize pattern-fit testing at the granular level. Every successful fit returns a Hedera transaction id, serving as an immutable proof-of-wear for social curation. Discipline: Fashion & Textile Design (virtual try-on). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Standardizing the 'cost per try-on' removes the friction of monthly subscriptions for casual shoppers while providing a high-volume revenue stream for digital tailors. It treats the rendering compute as a metered utility. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Drape" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-upcycleproof-chain-4-x402 Title: ThreadTrace · x402 Theme: Fashion & Textile Design (fashion) · recycled textile verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-attestation layer for sustainable fashion brands. Pay 0.01 USDC to generate a cryptographic proof-of-origin for a specific textile batch. Designers use these micro-certs to mint 'Verifiable Green' metadata on-chain, while resellers pay per-look-up to verify circularity. No subscriptions, just pay-per-stitch provenance. Why Hedera: Moving from a generic ledger to a per-item verification model makes sustainability data an affordable, granular commodity for small-scale upcyclers. Market: TAM $7.8B — The global textile recycling and circular economy certification market. | SAM $140M — The verifiable sustainable apparel market for boutique designers and circular brands. | SOM $12M — Independent upcycle designers and high-end thrift resellers using Base for provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-attestation layer for sustainable fashion brands. Pay 0.01 USDC to generate a cryptographic proof-of-origin for a specific textile batch. Designers use these micro-certs to mint 'Verifiable Green' metadata on-chain, while resellers pay per-look-up to verify circularity. No subscriptions, just pay-per-stitch provenance. Discipline: Fashion & Textile Design (recycled textile verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a generic ledger to a per-item verification model makes sustainability data an affordable, granular commodity for small-scale upcyclers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-trendsignal-oracles-5-x402 Title: TrendSignal · x402 Theme: Fashion & Textile Design (fashion) · trend prediction Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-pull fashion intelligence. A hyper-fragmented trend feed where every forecast, color palette, and fabric prediction is an indvidual x402-gated asset. Instead of bulky monthly subscriptions, designers and AI buying agents pay $0.01 USDC to decrypt specific 'provenance-backed' trend signals. Each micropayment triggers an on-chain verification of the source data (social sentiment + RWA sales), ensuring the signal isn't just noise. Pay to see the next silhouette before the market saturates. Why Hedera: Moves the business model from a $500/mo SaaS (barrier to entry) to a 'pay-per-insight' model. This allows independent designers and automated bots to consume specific data points without commitment, turning trend data into a liquid, metered commodity. Market: TAM $4.2B — Global fashion forecasting and market analysis sector. | SAM $850M — The addressable market of independent boutique labels, fast-fashion procurement bots, and decentralized apparel DAOs requiring real-time data. | SOM $12M — Initial capture of technical 'indie' designers and AI-driven automated dropshipping agents on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrendSignal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-pull fashion intelligence. A hyper-fragmented trend feed where every forecast, color palette, and fabric prediction is an indvidual x402-gated asset. Instead of bulky monthly subscriptions, designers and AI buying agents pay $0.01 USDC to decrypt specific 'provenance-backed' trend signals. Each micropayment triggers an on-chain verification of the source data (social sentiment + RWA sales), ensuring the signal isn't just noise. Pay to see the next silhouette before the market saturates. Discipline: Fashion & Textile Design (trend prediction). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves the business model from a $500/mo SaaS (barrier to entry) to a 'pay-per-insight' model. This allows independent designers and automated bots to consume specific data points without commitment, turning trend data into a liquid, metered commodity. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrendSignal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-patternshare-hub-6-x402 Title: ThreadFlow · x402 Theme: Fashion & Textile Design (fashion) · pattern licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless textile library where digital pattern blocks are metered via x402. Instead of bulk licenses, designers pay 0.01 USDC per 'stitch-view' or vector fragment. Every time a digital loom or 3D modeling software pulls a pattern coordinate, the creator is settled instantly. Shift from ownership to streaming fabric data. Why Hedera: Traditional licensing is too heavy for the fast-fashion cycle. x402 allows for granular 'pay-per-pull' geometry, turning patterns into a liquid asset class for AI fashion agents and digital tailors. Market: TAM $4.2B — Global textile design and licensing market moving toward automation. | SAM $120M — Digital fashion designers and boutique labels utilizing CAD/CLO3D workflows. | SOM $1.5M — Decentralized pattern makers and rapid-prototyping studios on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless textile library where digital pattern blocks are metered via x402. Instead of bulk licenses, designers pay 0.01 USDC per 'stitch-view' or vector fragment. Every time a digital loom or 3D modeling software pulls a pattern coordinate, the creator is settled instantly. Shift from ownership to streaming fabric data. Discipline: Fashion & Textile Design (pattern licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is too heavy for the fast-fashion cycle. x402 allows for granular 'pay-per-pull' geometry, turning patterns into a liquid asset class for AI fashion agents and digital tailors. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-costumenft-archive-7-x402 Title: THREADBARE · x402 Theme: Fashion & Textile Design (fashion) · historical costume catalog Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-resolution pattern and textile archive where every inspection costs $0.01. Instead of static NFTs, researchers and fashion designers pay per high-fidelity render or vector pattern download. Every 'look' at a 17th-century silk weave or 1920s flapper bead-work triggers a direct micropayment to the preserving museum's wallet. Perfect for AI fashion models needing training data or designers seeking authentic historical reference without a subscription. Why Hedera: Fashion history is currently locked behind high-cost art books or physical museum visits. By atomizing access to $0.01 per pattern/texture, you monetize the 'browse' and 'study' phases of design, turning a passive archive into an active revenue stream for cultural preservation. Market: TAM $6.8B — Global apparel design software market and museum digital licensing sector. | SAM $420M — Digital textile assets and historical reference market for the 1.2M global fashion designers and costume houses. | SOM $15M — Early adopters in digital fashion (CLO3D/Marvelous Designer users) and museum-led initiatives on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "THREADBARE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-resolution pattern and textile archive where every inspection costs $0.01. Instead of static NFTs, researchers and fashion designers pay per high-fidelity render or vector pattern download. Every 'look' at a 17th-century silk weave or 1920s flapper bead-work triggers a direct micropayment to the preserving museum's wallet. Perfect for AI fashion models needing training data or designers seeking authentic historical reference without a subscription. Discipline: Fashion & Textile Design (historical costume catalog). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Fashion history is currently locked behind high-cost art books or physical museum visits. By atomizing access to $0.01 per pattern/texture, you monetize the 'browse' and 'study' phases of design, turning a passive archive into an active revenue stream for cultural preservation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "THREADBARE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-stylistbooking-chain-8-x402 Title: LookBook · x402 Theme: Fashion & Textile Design (fashion) · consultation scheduling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-minute style consulting. Forget flat hourly bookings that lead to billing disputes. In LookBook, clients pay 0.01 USDC per minute for live video consultations or per-message advice via HTS transfer. The stream of micropayments acts as a real-time 'proof of presence,' ensuring stylists are compensated for every second of expertise without manual escrow. No prepayments, no refunds, just a continuous flow of value settled on Hedera. Why Hedera: By replacing upfront deposits with per-minute or per-message micropayments, the app eliminates the friction of 'booking' entirely. The payment becomes the clock, turning expertise into a liquid utility. Market: TAM $28B Total Addressable Market for micro-consultation and professional scheduling services. | SAM $4.2B global personal styling market migrating to digital-first, fragmented gig models. | SOM $85M within the high-end streetwear and digital-native creator economy utilizing real-time consulting. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LookBook" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-minute style consulting. Forget flat hourly bookings that lead to billing disputes. In LookBook, clients pay 0.01 USDC per minute for live video consultations or per-message advice via HTS transfer. The stream of micropayments acts as a real-time 'proof of presence,' ensuring stylists are compensated for every second of expertise without manual escrow. No prepayments, no refunds, just a continuous flow of value settled on Hedera. Discipline: Fashion & Textile Design (consultation scheduling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing upfront deposits with per-minute or per-message micropayments, the app eliminates the friction of 'booking' entirely. The payment becomes the clock, turning expertise into a liquid utility. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LookBook" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-sustainlabel-verify-9-x402 Title: THREADSCAN · x402 Theme: Fashion & Textile Design (fashion) · eco-label validation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-scan validation layer for the circular economy. Brands and resale platforms pay 0.01 USDC to instantly verify a garment's eco-credentials (GOTS, OEKO-TEX, B Corp) against off-chain certification databases via x402. Every scan generates a cryptographically signed receipt on Hedera, turning 'greenwashing' into a financial liability by metering the cost of proof. Why Hedera: By moving from a subscription model to a pay-per-verification model, small eco-boutiques and individual vintage resellers can access high-tier textile provenance tools without overhead, while large aggregators can automate agent-driven verification at scale. Market: TAM $2.4B — The global textile traceability and supply chain transparency market. | SAM $420M — The sustainable apparel auditing and certification market. | SOM $18M — Micro-verifications for boutique eco-labels and high-end resale platforms on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "THREADSCAN" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-scan validation layer for the circular economy. Brands and resale platforms pay 0.01 USDC to instantly verify a garment's eco-credentials (GOTS, OEKO-TEX, B Corp) against off-chain certification databases via x402. Every scan generates a cryptographically signed receipt on Hedera, turning 'greenwashing' into a financial liability by metering the cost of proof. Discipline: Fashion & Textile Design (eco-label validation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a subscription model to a pay-per-verification model, small eco-boutiques and individual vintage resellers can access high-tier textile provenance tools without overhead, while large aggregators can automate agent-driven verification at scale. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "THREADSCAN" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-biofiber-token-10-x402 Title: SporePrint · x402 Theme: Fashion & Textile Design (fashion) · biomaterial crowdfunding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-sample terminal where designers unlock genetic recipes and fabrication protocols for mycelium, algae, and bacterial cellulose. Instead of bulky crowdfunding rounds, innovators monetize 'Lab-Drops'—charging 0.01 USDC per recipe download or growth-parameter update, creating a real-time revenue stream for sustainable material R&D. Why Hedera: Biomaterial R&D is currently gated by high academic walls or opaque startups. By atomizing access to protocols into 0.01 USDC micropayments, creators get instant liquidity while designers get high-fidelity material instructions without a subscription. Market: TAM $12B — The global bio-based textile market moving toward decentralized open-source innovation. | SAM $250M — The digital fashion and sustainable textile prototyping market using on-chain documentation. | SOM $15M — Early-adopter bio-designers and independent fashion houses prototyping with alternative leathers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SporePrint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-sample terminal where designers unlock genetic recipes and fabrication protocols for mycelium, algae, and bacterial cellulose. Instead of bulky crowdfunding rounds, innovators monetize 'Lab-Drops'—charging 0.01 USDC per recipe download or growth-parameter update, creating a real-time revenue stream for sustainable material R&D. Discipline: Fashion & Textile Design (biomaterial crowdfunding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Biomaterial R&D is currently gated by high academic walls or opaque startups. By atomizing access to protocols into 0.01 USDC micropayments, creators get instant liquidity while designers get high-fidelity material instructions without a subscription. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SporePrint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fashioncollab-dao-11-x402 Title: Stitchflow · x402 Theme: Fashion & Textile Design (fashion) · collaborative design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A modular design canvas where every brushstroke, pattern overlay, or seam tweak is a $0.01 micro-contribution. Instead of bulky governance votes, the design evolves through 'Proof of Stake' aesthetics; designers pay to commit changes to the master garment file. This creates a real-time, skin-in-the-game leaderboard for lead designers, where the final high-fidelity tech pack is unlocked and exported via a collective micropayment pool. Why Hedera: Shifts collaboration from slow DAO voting to high-frequency aesthetic iteration. x402 turns the canvas into a metered coordinate system, ensuring every contributor has economic weight behind their design choices. Market: TAM $1.7T — The global apparel production and design management industry. | SAM $480M — The growing digital-native 'phygital' fashion market and 3D asset creators. | SOM $12M — Web3-native fashion houses and independent street-wear designers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stitchflow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A modular design canvas where every brushstroke, pattern overlay, or seam tweak is a $0.01 micro-contribution. Instead of bulky governance votes, the design evolves through 'Proof of Stake' aesthetics; designers pay to commit changes to the master garment file. This creates a real-time, skin-in-the-game leaderboard for lead designers, where the final high-fidelity tech pack is unlocked and exported via a collective micropayment pool. Discipline: Fashion & Textile Design (collaborative design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts collaboration from slow DAO voting to high-frequency aesthetic iteration. x402 turns the canvas into a metered coordinate system, ensuring every contributor has economic weight behind their design choices. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stitchflow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-supplychain-mint-12-x402 Title: LoomState · x402 Theme: Fashion & Textile Design (fashion) · production tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Thread-level accountability for luxury manufacturing. Instead of manual audits, factories trigger x402-signed events for every cut, stitch, and wash. The brand pays to log the data, and the factory earns instant USDC for the verification. No payment, no proof. Consumers pay $0.01 per garment scan to see the immutable 'Birth Certificate' of their clothes, funding the continuous monitoring of the loom. Why Hedera: Shifts supply chain tracking from a passive log to a meter-based verification system where transparency is bought in real-time. Market: TAM $12B — The global luxury goods traceability and anti-counterfeiting market. | SAM $850M — High-end fashion houses and 'conscious' streetwear brands requiring verifiable ESG metrics. | SOM $45M — Niche luxury ateliers and sustainable textile startups on Hedera utilizing automated verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LoomState" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Thread-level accountability for luxury manufacturing. Instead of manual audits, factories trigger x402-signed events for every cut, stitch, and wash. The brand pays to log the data, and the factory earns instant USDC for the verification. No payment, no proof. Consumers pay $0.01 per garment scan to see the immutable 'Birth Certificate' of their clothes, funding the continuous monitoring of the loom. Discipline: Fashion & Textile Design (production tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts supply chain tracking from a passive log to a meter-based verification system where transparency is bought in real-time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LoomState" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-stylelicense-nft-13-x402 Title: VOGUEPRINT · x402 Theme: Fashion & Textile Design (fashion) · style licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Style-as-an-API for generative fashion. Instead of static minting, designers expose their unique aesthetic DNA (patterns, cuts, fabric logic) via a gated endpoint. Each time a digital creator or AI-commerce tool generates a garment using your 'Style Signature,' the x402 protocol triggers a 0.01 USDC micro-royalty. Settlement is instant, verifiable on Hedera, and eliminates the friction of traditional licensing contracts. Why Hedera: Shifts 'licensing' from a legal static document to an active, metered utility. By charging per generation/call, it creates a high-velocity revenue stream for designers where payment is the proof of permission. Market: TAM $4.2B — The global Intellectual Property licensing market for apparel and textiles, moving on-chain. | SAM $850M — The digital fashion and virtual goods market, transitioning toward AI-assisted design tools. | SOM $12M — Independent textile designers and boutique CGI fashion houses integrating with automated generation pipelines. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VOGUEPRINT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Style-as-an-API for generative fashion. Instead of static minting, designers expose their unique aesthetic DNA (patterns, cuts, fabric logic) via a gated endpoint. Each time a digital creator or AI-commerce tool generates a garment using your 'Style Signature,' the x402 protocol triggers a 0.01 USDC micro-royalty. Settlement is instant, verifiable on Hedera, and eliminates the friction of traditional licensing contracts. Discipline: Fashion & Textile Design (style licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts 'licensing' from a legal static document to an active, metered utility. By charging per generation/call, it creates a high-velocity revenue stream for designers where payment is the proof of permission. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VOGUEPRINT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fabricbatch-chain-14-x402 Title: ThreadSeal · x402 Theme: Fashion & Textile Design (fashion) · batch quality control Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-metered verification protocol where garment manufacturers pay 0.01 USDC to unlock an immutable quality seal for individual fabric bolts. Designers pay per lookup to verify tensile strength, dye-lot consistency, and compliance certificates before cutting patterns. Pay-per-batch eliminates heavy subscription costs for independent ateliers. Why Hedera: Moving from a broad 'log' system to a granular micro-payment model turns quality data into a liquid asset. It prevents 'data dumping' by making each entry a paid certification and each retrieval a paid verification, ensuring only high-fidelity data consumes chain space. Market: TAM $3.2B — The global textile quality control and supply chain traceability market transitioning to real-time auditing. | SAM $12M — Emerging D2C fashion brands and boutique textile mills adopting digital passports. | SOM $850K — Initial pilot with premium Italian and Japanese mills using Base for export compliance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadSeal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-metered verification protocol where garment manufacturers pay 0.01 USDC to unlock an immutable quality seal for individual fabric bolts. Designers pay per lookup to verify tensile strength, dye-lot consistency, and compliance certificates before cutting patterns. Pay-per-batch eliminates heavy subscription costs for independent ateliers. Discipline: Fashion & Textile Design (batch quality control). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a broad 'log' system to a granular micro-payment model turns quality data into a liquid asset. It prevents 'data dumping' by making each entry a paid certification and each retrieval a paid verification, ensuring only high-fidelity data consumes chain space. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadSeal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-wearablestats-token-15-x402 Title: WarpStream · x402 Theme: Fashion & Textile Design (fashion) · performance textiles Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity telemetry bridge for performance textiles where apparel brands pay users for real-world stress-test data. Instead of vague rewards, each mile run or gram of sweat wicked triggers an x402 data-unlock. Professional athletes and R&D labs pay 0.01 USDC per kilometer of anonymized sensor data (tensile strength, thermal regulation, moisture rates) to refine future fiber compositions. Wearables become revenue-generating assets for the wearer. Why Hedera: Shifting from 'reward tokens' to a 'data-metering' model creates a direct market for performance metrics. Performance textile R&D is starving for high-velocity real-world data; x402 allows for granular, per-pulse data acquisitions that are too micro for traditional payment rails. Market: TAM $196B — Global performance apparel and smart textile market. | SAM $840M — Professional sports analytics and smart-garment R&D spend. | SOM $12M — Data-harvesting for early-adopter technical apparel brands on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "WarpStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity telemetry bridge for performance textiles where apparel brands pay users for real-world stress-test data. Instead of vague rewards, each mile run or gram of sweat wicked triggers an x402 data-unlock. Professional athletes and R&D labs pay 0.01 USDC per kilometer of anonymized sensor data (tensile strength, thermal regulation, moisture rates) to refine future fiber compositions. Wearables become revenue-generating assets for the wearer. Discipline: Fashion & Textile Design (performance textiles). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from 'reward tokens' to a 'data-metering' model creates a direct market for performance metrics. Performance textile R&D is starving for high-velocity real-world data; x402 allows for granular, per-pulse data acquisitions that are too micro for traditional payment rails. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "WarpStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-virtualrunway-dao-16-x402 Title: FrontRow · x402 Theme: Fashion & Textile Design (fashion) · digital fashion shows Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-frame digital front row. Viewers pay 0.01 USDC per minute of high-fidelity 4K stream or per high-res texture close-up. Designers earn instant micro-royalties every time a spectator 'touches' a garment's metadata or captures a screen-ready 3D render. No subscriptions, just a metered gate on the couture stream. Why Hedera: Shifts the model from a static event gate to granular consumption. By metering the stream and assets, the app captures value from casual observers and power-users (buyers/press) differently, ensuring creators are paid for every second of engagement via non-custodial signatures. Market: TAM $6.6B — The global virtual events and digital twin fashion market, increasingly driven by AI-generated assets. | SAM $850M — The projected market for digital-only fashion assets and virtual luxury events by 2026. | SOM $42M — Initial capture from high-end niche digital fashion houses and metaverse-native streetwear collectors. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrontRow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-frame digital front row. Viewers pay 0.01 USDC per minute of high-fidelity 4K stream or per high-res texture close-up. Designers earn instant micro-royalties every time a spectator 'touches' a garment's metadata or captures a screen-ready 3D render. No subscriptions, just a metered gate on the couture stream. Discipline: Fashion & Textile Design (digital fashion shows). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from a static event gate to granular consumption. By metering the stream and assets, the app captures value from casual observers and power-users (buyers/press) differently, ensuring creators are paid for every second of engagement via non-custodial signatures. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrontRow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-textilewaste-nft-17-x402 Title: ThreadTrace · x402 Theme: Fashion & Textile Design (fashion) · waste management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular audit protocol for the circular economy. Brands or recyclers pay 0.01 USDC to mint a verified 'Disposal Proof' for every kilogram of textile waste processed. These proofs are micro-signed metadata packets that aggregate into a verifiable on-chain footprint, allowing designers to 'unlock' authentic recycled sourcing data per-query. No bulk subscriptions—pay only for the waste you verify or the source you trace. Why Hedera: By shifting from high-friction NFT mints to high-frequency x402 micropayments, the cost of auditing waste scales linearly with the volume of material. This turns every kg of waste into a billable, verifiable event, making circularity data affordable for small designers and scalable for industrial recyclers. Market: TAM $2.8B — Global textile waste management and ESG reporting software market. | SAM $450M — Modern circularity compliance and green-labeling markets in the EU and US. | SOM $12M — Early-adopter sustainable apparel startups and boutique recycling facilities on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular audit protocol for the circular economy. Brands or recyclers pay 0.01 USDC to mint a verified 'Disposal Proof' for every kilogram of textile waste processed. These proofs are micro-signed metadata packets that aggregate into a verifiable on-chain footprint, allowing designers to 'unlock' authentic recycled sourcing data per-query. No bulk subscriptions—pay only for the waste you verify or the source you trace. Discipline: Fashion & Textile Design (waste management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from high-friction NFT mints to high-frequency x402 micropayments, the cost of auditing waste scales linearly with the volume of material. This turns every kg of waste into a billable, verifiable event, making circularity data affordable for small designers and scalable for industrial recyclers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-patternproof-chain-18-x402 Title: PatternProof · x402 Theme: Fashion & Textile Design (fashion) · pattern authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micropayment-gated library for high-res textile vectors. Designers pay $0.01 per high-fidelity render to verify authenticity and watermark their work on-chain. Large-scale fashion labels use automated agents to query the database, paying per search to ensure new seasonal prints don't infringe on independent creator IP. Payment is the proof — every transaction logs a timestamped claim to the pattern’s origin. Why Hedera: Shifts 'verification' from a static record to a pay-per-search/pay-per-render utility. It turns the registry into a metered API for the fashion industry. Market: TAM $28B — Global textile design and pattern licensing sector. | SAM $1.2B — The fast-fashion IP compliance and design licensing market. | SOM $8.5M — Independent print designers and boutique labels requiring automated IP protection. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PatternProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micropayment-gated library for high-res textile vectors. Designers pay $0.01 per high-fidelity render to verify authenticity and watermark their work on-chain. Large-scale fashion labels use automated agents to query the database, paying per search to ensure new seasonal prints don't infringe on independent creator IP. Payment is the proof — every transaction logs a timestamped claim to the pattern’s origin. Discipline: Fashion & Textile Design (pattern authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts 'verification' from a static record to a pay-per-search/pay-per-render utility. It turns the registry into a metered API for the fashion industry. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PatternProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-accessoryauth-mint-19-x402 Title: VeriStitch · x402 Theme: Fashion & Textile Design (fashion) · accessory authentication Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographic provenance layer for high-end accessories. Brands embed encrypted NFC tags; users pay 0.01 USDC to instantly verify authenticity via an HTS transfer signed call. Each scan triggers a signed transaction that updates the item's 'last-seen' status on Hedera, creating a tamper-proof chain of custody without friction or gas-stress. Pay-per-scan eliminates subscription barriers for second-hand verification. Why Hedera: Legacy authentication relies on expensive appraisals or static certificates. By making the 'verification' a high-frequency, low-cost micropayment, we turn fashion items into active data-producing nodes. Brands earn a stream from secondary market activity (validation fees), and buyers gain instant certainty for a cent. Market: TAM $28B — Total Addressable Market for global fashion anti-counterfeiting and provenance tracking. | SAM $2.8B — Global luxury resale and authentication service market. | SOM $140M — Verification fees for limited-edition sneakers and handbags within the Base/HashPack ecosystem. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VeriStitch" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographic provenance layer for high-end accessories. Brands embed encrypted NFC tags; users pay 0.01 USDC to instantly verify authenticity via an HTS transfer signed call. Each scan triggers a signed transaction that updates the item's 'last-seen' status on Hedera, creating a tamper-proof chain of custody without friction or gas-stress. Pay-per-scan eliminates subscription barriers for second-hand verification. Discipline: Fashion & Textile Design (accessory authentication). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Legacy authentication relies on expensive appraisals or static certificates. By making the 'verification' a high-frequency, low-cost micropayment, we turn fashion items into active data-producing nodes. Brands earn a stream from secondary market activity (validation fees), and buyers gain instant certainty for a cent. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VeriStitch" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fashioncontractor-dao-20-x402 Title: StitchGate · x402 Theme: Fashion & Textile Design (fashion) · freelance contract management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-action protocol for fashion freelancers to secure deliverable milestones. Instead of lump-sum escrow, designers gate technical packs, pattern files, and CAD renders behind 0.01 USDC micro-unlocks. Clients pay per view or per download, providing instant liquidity to the creator for every iteration phase. The facilitator settles the transaction, instantly releasing the signature-wrapped design files. Why Hedera: Traditional escrow is too heavy for small design iterations. x402 enables 'streaming deliverables' where the designer gets paid for the micro-work of each adjustment, reducing the risk of client ghosting after viewing a draft. Market: TAM $28B — The total addressable freelance apparel design and supply chain management sector. | SAM $850M — The global digital fashion and freelance textile design outsourcing market. | SOM $12M — Independent pattern makers and technical designers on Hedera using automated micropayment gates for asset delivery. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StitchGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-action protocol for fashion freelancers to secure deliverable milestones. Instead of lump-sum escrow, designers gate technical packs, pattern files, and CAD renders behind 0.01 USDC micro-unlocks. Clients pay per view or per download, providing instant liquidity to the creator for every iteration phase. The facilitator settles the transaction, instantly releasing the signature-wrapped design files. Discipline: Fashion & Textile Design (freelance contract management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional escrow is too heavy for small design iterations. x402 enables 'streaming deliverables' where the designer gets paid for the micro-work of each adjustment, reducing the risk of client ghosting after viewing a draft. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StitchGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-digitalswatch-token-21-x402 Title: SWATCH · x402 Theme: Fashion & Textile Design (fashion) · digital fabric samples Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Digitize physical textiles into high-fidelity PBR (Physically Based Rendering) nodes. Designers pay 0.01 USDC to pull a single 'Thread-Stream'—real-time rendering data for a specific fabric. Each micro-payment unlocks the raw texture maps and physics parameters for CLO3D/Browzwear integration, ensuring designers only pay for the yardage they digitally 'cut' and manufacturers get paid per simulation. Why Hedera: By moving from bulk licensing to pay-per-render-call, the app eliminates the high barrier for independent designers while providing a continuous revenue stream for textile mills based on actual usage in digital catwalks. Market: TAM $26B — Global textile manufacturing and B2B wholesale supply chain digitization. | SAM $950M — The growing digital fashion and virtual try-on market requiring high-fidelity assets. | SOM $45M — Niche 3D apparel designers and indie fashion labels on Hedera using real-time simulation tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SWATCH" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Digitize physical textiles into high-fidelity PBR (Physically Based Rendering) nodes. Designers pay 0.01 USDC to pull a single 'Thread-Stream'—real-time rendering data for a specific fabric. Each micro-payment unlocks the raw texture maps and physics parameters for CLO3D/Browzwear integration, ensuring designers only pay for the yardage they digitally 'cut' and manufacturers get paid per simulation. Discipline: Fashion & Textile Design (digital fabric samples). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from bulk licensing to pay-per-render-call, the app eliminates the high barrier for independent designers while providing a continuous revenue stream for textile mills based on actual usage in digital catwalks. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SWATCH" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-ecofashion-dao-22-x402 Title: THREADTRACE · x402 Theme: Fashion & Textile Design (fashion) · sustainability governance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular traceability layer for sustainable supply chains. Every time a brand, auditor, or consumer fetches verified 'Proof of Origin' data for a garment, the creator—or the material's source—receives 0.01 USDC. Payment acts as the signal of truth: data isn't just open; it's metered for authenticity, ensuring that green-claims are backed by cost-of-verification. Accessing a textile's carbon footprint or fair-trade certificate triggers a direct micro-settlement to the certifying NGO or sensor-node owner. Why Hedera: Moves sustainability from a passive 'DAO vote' to an active 'fee-per-proof' model. By charging 0.01 USDC per audit pull, it turns governance into a revenue stream for local producers and green auditors, preventing data scraping without attribution. Market: TAM $95B — Global compliance, ESG auditing, and textile traceability market as brands move toward Digital Product Passports (DPP). | SAM $420M — Web3-integrated apparel brands and independent sustainable designers using HTS transfer for automated supply-chain transparency. | SOM $12M — Early adopters in the 'Phygital' fashion space on Hedera needing on-chain provenance for NFC-tagged garments. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "THREADTRACE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular traceability layer for sustainable supply chains. Every time a brand, auditor, or consumer fetches verified 'Proof of Origin' data for a garment, the creator—or the material's source—receives 0.01 USDC. Payment acts as the signal of truth: data isn't just open; it's metered for authenticity, ensuring that green-claims are backed by cost-of-verification. Accessing a textile's carbon footprint or fair-trade certificate triggers a direct micro-settlement to the certifying NGO or sensor-node owner. Discipline: Fashion & Textile Design (sustainability governance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves sustainability from a passive 'DAO vote' to an active 'fee-per-proof' model. By charging 0.01 USDC per audit pull, it turns governance into a revenue stream for local producers and green auditors, preventing data scraping without attribution. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "THREADTRACE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-runwayroyalties-nft-23-x402 Title: StitchCheck · x402 Theme: Fashion & Textile Design (fashion) · event royalty management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Replace opaque agency billing with granular attribution for every piece of content captured on the catwalk. Runway designers meter the digital footprint of their show: every high-res photo download by press, every AI style-transfer training call on a garment pattern, and every lookbook scrape triggers a 0.01 USDC micro-royalty. Settlement happens in real-time to the designer, model, and stylist's Magic Link email sign-ins, moving fashion from 'lump-sum' contracts to a 'pay-per-gaze' economy. Why Hedera: Current runway royalties are lost in legal paperwork; x402 enables automated, sub-cent attribution at the moment of digital consumption or commercial reuse. Market: TAM $4.2B — The global event management and textile IP licensing market transitioning to automated digital distribution. | SAM $850M — The digital rights and licensing market for global fashion weeks and high-end editorial archives. | SOM $12M — Micro-licensing fees for independent designers during NYC/Paris/London fringe events and digital-native fashion houses. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StitchCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Replace opaque agency billing with granular attribution for every piece of content captured on the catwalk. Runway designers meter the digital footprint of their show: every high-res photo download by press, every AI style-transfer training call on a garment pattern, and every lookbook scrape triggers a 0.01 USDC micro-royalty. Settlement happens in real-time to the designer, model, and stylist's Magic Link email sign-ins, moving fashion from 'lump-sum' contracts to a 'pay-per-gaze' economy. Discipline: Fashion & Textile Design (event royalty management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current runway royalties are lost in legal paperwork; x402 enables automated, sub-cent attribution at the moment of digital consumption or commercial reuse. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StitchCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-styleswap-chain-24-x402 Title: StyleSwap · x402 Theme: Fashion & Textile Design (fashion) · digital fashion exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: StyleSwap allows designers to monetize digital wardrobes through micro-rentals. Instead of buying a skin, users pay $0.01 per high-res render or AR-overlay session. Every time a digital garment is 'worn' in a photo or stream, the x402 payment settles instantly to the creator's wallet. Pay-per-wear fashion for the hyper-fluid influencer economy. Why Hedera: Moves digital fashion from a static 'ownership' model to a utility-based 'usage' model. x402 handles the high-frequency, low-latency micro-settlements required for active wardrobe swapping without the friction of large upfront NFT costs. Market: TAM $1.9B — The global digital fashion and virtual goods market transitioning to utility-based consumption. | SAM $350M — The digital-native creator economy and social media influencers using AR filters and digital assets for content. | SOM $18M — High-velocity digital fashion enthusiasts and streamers on Hedera seeking low-cost, instant outfit changes. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StyleSwap" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT StyleSwap allows designers to monetize digital wardrobes through micro-rentals. Instead of buying a skin, users pay $0.01 per high-res render or AR-overlay session. Every time a digital garment is 'worn' in a photo or stream, the x402 payment settles instantly to the creator's wallet. Pay-per-wear fashion for the hyper-fluid influencer economy. Discipline: Fashion & Textile Design (digital fashion exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves digital fashion from a static 'ownership' model to a utility-based 'usage' model. x402 handles the high-frequency, low-latency micro-settlements required for active wardrobe swapping without the friction of large upfront NFT costs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StyleSwap" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fabric-storyline-0-x402 Title: Threadline · x402 Theme: Fashion & Textile Design (fashion) · digital textile archives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized swatch library where every high-res inspection, pattern download, or commercial moodboard export is a 0.01 USDC micro-transaction. Designers monetize their textile archives by the pixel, using x402 to bypass subscriptions and legacy licensing fees. Each 'View' or 'Draft' action generates a signed HTS transfer authorization, providing an immutable audit trail of who accessed the design and when, protecting IP through granular, paid provenance. Why Hedera: By moving away from a 'vault' model to a 'metered access' model, the app turns a static archive into a liquid marketplace. Creators get paid for every single look, and fashion houses pay only for the inspiration they actually interact with. Market: TAM $28B — Global textile design and fashion IP market, including digital-physical licensing. | SAM $1.2B — The growing market for digital fashion assets, 3D garment simulation, and virtual textile twins. | SOM $15M — Independent textile designers and boutique fabric houses seeking per-use monetization on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Threadline" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized swatch library where every high-res inspection, pattern download, or commercial moodboard export is a 0.01 USDC micro-transaction. Designers monetize their textile archives by the pixel, using x402 to bypass subscriptions and legacy licensing fees. Each 'View' or 'Draft' action generates a signed HTS transfer authorization, providing an immutable audit trail of who accessed the design and when, protecting IP through granular, paid provenance. Discipline: Fashion & Textile Design (digital textile archives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving away from a 'vault' model to a 'metered access' model, the app turns a static archive into a liquid marketplace. Creators get paid for every single look, and fashion houses pay only for the inspiration they actually interact with. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Threadline" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-runway-replay-1-x402 Title: Archiv-1 · x402 Theme: Fashion & Textile Design (fashion) · fashion show documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to archive a high-fidelity runway look or metadata packet to a permanent, verifiable ledger. A decentralized 'Front Row' vault where every save is a micro-settlement ensuring the permanence of haute couture data. Accessing the historic archive triggers a micropayment to the original photographer or house. Why Hedera: Shifts show documentation from a passive gallery to a metered archival protocol. By making the 'pin' a paid event, it prevents data rot and ensures that high-resolution documentation is treated as a premium asset rather than commodity social media content. x402 handles the 'proof of preservation' via the transaction hash. Market: TAM $4.2B — The global luxury fashion market's heritage and authentication data layer. | SAM $450M — The digital fashion archival and trend-forecasting market, transitioning to pay-per-reference models. | SOM $12M — Independent luxury houses and fashion historians using metered archival APIs for collection verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Archiv-1" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to archive a high-fidelity runway look or metadata packet to a permanent, verifiable ledger. A decentralized 'Front Row' vault where every save is a micro-settlement ensuring the permanence of haute couture data. Accessing the historic archive triggers a micropayment to the original photographer or house. Discipline: Fashion & Textile Design (fashion show documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts show documentation from a passive gallery to a metered archival protocol. By making the 'pin' a paid event, it prevents data rot and ensures that high-resolution documentation is treated as a premium asset rather than commodity social media content. x402 handles the 'proof of preservation' via the transaction hash. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Archiv-1" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-color-code-vault-2-x402 Title: Pigment State · x402 Theme: Fashion & Textile Design (fashion) · color palette preservation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity digital archive where designers 'stamp' color-accurate palettes to the blockchain. Every time a brand, manufacturer, or AI renderer calls the vault to fetch a hex-matched profile or verify a fabric dye-lot, 0.01 USDC is settled. It transforms a static stylesheet into a metered source of truth, ensuring the original designer is paid for every lookbook generated or sample produced based on their proprietary color theory. Why Hedera: Traditional color books are static PDFs; x402 turns them into live, billable APIs. By charging per 'fetch,' the app monetizes the expertise of colorists and prevents style-theft by creating an on-chain paper trail of usage. Market: TAM $2.8B — The global textile design and color management software market moving toward automated, verifiable supply chains. | SAM $400M — The addressable market for digital fashion assets and pro-tier design system licensing. | SOM $12M — Specialized boutique textile designers and independent fashion houses on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Pigment State" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity digital archive where designers 'stamp' color-accurate palettes to the blockchain. Every time a brand, manufacturer, or AI renderer calls the vault to fetch a hex-matched profile or verify a fabric dye-lot, 0.01 USDC is settled. It transforms a static stylesheet into a metered source of truth, ensuring the original designer is paid for every lookbook generated or sample produced based on their proprietary color theory. Discipline: Fashion & Textile Design (color palette preservation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional color books are static PDFs; x402 turns them into live, billable APIs. By charging per 'fetch,' the app monetizes the expertise of colorists and prevents style-theft by creating an on-chain paper trail of usage. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Pigment State" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-stitch-trace-3-x402 Title: ATELIER · x402 Theme: Fashion & Textile Design (fashion) · garment construction logs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular ledger for master tailors and couture houses. Pay-per-entry to timestamp construction phases (dart placement, seam finishing) with photographic proof. Designers pay to lock proprietary draping techniques; apprentices and AI garment-simulators pay per-view to access verified 'Digital Twin' build-sheets. Fractionalize the value of a garment's labor, not just its name. Why Hedera: By making every log entry a micro-transaction, the 'proof of work' is economically weighted. The x402 model turns a static log into a metered technical library where creators are paid every time a manufacturer or student accesses their specific construction IP. Market: TAM $3.8B — Global apparel construction and technical design market, transitioning toward digital product passports and automated manufacturing instructions. | SAM $420M — Professional couturiers, luxury brand archives, and bespoke tailoring houses requiring verifiable provenance for high-ticket items. | SOM $12M — Independent slow-fashion designers and circular-fashion repair logs using Base for transparent supply-chain documentation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ATELIER" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular ledger for master tailors and couture houses. Pay-per-entry to timestamp construction phases (dart placement, seam finishing) with photographic proof. Designers pay to lock proprietary draping techniques; apprentices and AI garment-simulators pay per-view to access verified 'Digital Twin' build-sheets. Fractionalize the value of a garment's labor, not just its name. Discipline: Fashion & Textile Design (garment construction logs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making every log entry a micro-transaction, the 'proof of work' is economically weighted. The x402 model turns a static log into a metered technical library where creators are paid every time a manufacturer or student accesses their specific construction IP. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ATELIER" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-moodboard-ledger-4-x402 Title: VOGUE PROOF · x402 Theme: Fashion & Textile Design (fashion) · inspiration curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographically timestamped canvas for creative direction. Pay 0.01 USDC to 'pin' an inspiration source, locking it to a permanent on-chain moodboard. Each pin generates an on-chain attribution hash, ensuring your aesthetic evolution is provable and immutable before you launch a collection. Payment is the act of curation: if it's not worth a cent, it's noise. Why Hedera: Transitions a static private tool into a 'pay-to-validate' creative ledger. By making every addition a micro-transaction, the designer curated with higher intent, while the facilitator settles the attribution data on Hedera. Market: TAM $2.4B — Global fashion design software and creative asset management market. | SAM $120M — Prolific independent designers and creative directors using digital curation tools. | SOM $8.5M — Emerging 'on-chain' fashion houses and digital-native creators requiring provenance for their design process. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VOGUE PROOF" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographically timestamped canvas for creative direction. Pay 0.01 USDC to 'pin' an inspiration source, locking it to a permanent on-chain moodboard. Each pin generates an on-chain attribution hash, ensuring your aesthetic evolution is provable and immutable before you launch a collection. Payment is the act of curation: if it's not worth a cent, it's noise. Discipline: Fashion & Textile Design (inspiration curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitions a static private tool into a 'pay-to-validate' creative ledger. By making every addition a micro-transaction, the designer curated with higher intent, while the facilitator settles the attribution data on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VOGUE PROOF" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-trend-lineage-5-x402 Title: Loom State · x402 Theme: Fashion & Textile Design (fashion) · fashion trend mapping Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographically verifiable ledger of trend evolution. Designers and brands pay 0.01 USDC to 'pull a thread'—unlocking the high-res visual evidence and on-chain timestamp of a specific trend's origin. Whether it's a specific silhouette or a textile pattern, every query settles a micropayment to the original trend spotter. Prevents aesthetic plagiarism and rewards the first-to-capture. Why Hedera: By turning trend discovery into a pay-per-view primitive, we solve the 'attribution gap' in fashion. The x402 model ensures that trend-forecasters are paid instantly for their research, while designers get verifiable proof of inspiration for their moodboards. Market: TAM $3.1B — The global fashion forecasting and intellectual property market. | SAM $450M — Fashion designers and trend forecasting departments utilizing on-chain archives. | SOM $12M — Independent street-style photographers and archival fashion collectors on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Loom State" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographically verifiable ledger of trend evolution. Designers and brands pay 0.01 USDC to 'pull a thread'—unlocking the high-res visual evidence and on-chain timestamp of a specific trend's origin. Whether it's a specific silhouette or a textile pattern, every query settles a micropayment to the original trend spotter. Prevents aesthetic plagiarism and rewards the first-to-capture. Discipline: Fashion & Textile Design (fashion trend mapping). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning trend discovery into a pay-per-view primitive, we solve the 'attribution gap' in fashion. The x402 model ensures that trend-forecasters are paid instantly for their research, while designers get verifiable proof of inspiration for their moodboards. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Loom State" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-texture-token-6-x402 Title: WeaveVault · x402 Theme: Fashion & Textile Design (fashion) · fabric texture archives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity textile oracle where designers pay 0.01 USDC to unlock raw 8K displacement maps and weave patterns. Instead of heavy subscriptions, fashion labels pay-per-sample to verify rare weaves, download print-ready files, and secure IP-protected archives for digital manufacturing. Each unlock triggers an instant Base settlement to the original weaver. Why Hedera: Moving from a static 'token' to a 'metered archive' turns high-res textures into liquid IP. The x402 primitive allows for granular consumption of heavy assets, making high-end textile data accessible to indie designers while providing immediate micro-royalties to textile mills. Market: TAM $28B — Global textile manufacturing and R&D verification sector. | SAM $1.2B — Digital fashion assets and technical textile design software revenues. | SOM $45M — Niche 3D textile rendering for sustainable prototyping and luxury house archives. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "WeaveVault" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity textile oracle where designers pay 0.01 USDC to unlock raw 8K displacement maps and weave patterns. Instead of heavy subscriptions, fashion labels pay-per-sample to verify rare weaves, download print-ready files, and secure IP-protected archives for digital manufacturing. Each unlock triggers an instant Base settlement to the original weaver. Discipline: Fashion & Textile Design (fabric texture archives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a static 'token' to a 'metered archive' turns high-res textures into liquid IP. The x402 primitive allows for granular consumption of heavy assets, making high-end textile data accessible to indie designers while providing immediate micro-royalties to textile mills. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "WeaveVault" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fit-file-safe-7-x402 Title: StitchGuard · x402 Theme: Fashion & Textile Design (fashion) · 3D garment fitting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Micro-metered precision for virtual ateliers. $0.01 per garment node update or measurement sync. Designers pay per precise fit-check, ensuring 1:1 digital twins without flat subscription waste. Every body-scan retrieval is a secure, paid handshake between the fit-engine and the tailor's workspace. Why Hedera: By atomizing the fitting process into pay-per-sync events, we eliminate the high overhead of professional 3D design suites. Tailors only pay for the exact compute used during a client session, settled instantly via USDC. Market: TAM $4.2B - The global virtual fitting room and automated apparel manufacturing industry. | SAM $850M - The digital-to-physical bespoke tailoring market and 3D prototyping sector. | SOM $12M - Boutique digital ateliers and independent 3D garment designers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StitchGuard" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Micro-metered precision for virtual ateliers. $0.01 per garment node update or measurement sync. Designers pay per precise fit-check, ensuring 1:1 digital twins without flat subscription waste. Every body-scan retrieval is a secure, paid handshake between the fit-engine and the tailor's workspace. Discipline: Fashion & Textile Design (3D garment fitting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By atomizing the fitting process into pay-per-sync events, we eliminate the high overhead of professional 3D design suites. Tailors only pay for the exact compute used during a client session, settled instantly via USDC. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StitchGuard" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-accessory-archive-8-x402 Title: VaultLink · x402 Theme: Fashion & Textile Design (fashion) · digital accessory cataloging Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Protect and monetize your digital archive. Every entry of high-fidelity accessory data (meshes, textures, tech specs) is encrypted. Users pay 0.01 USDC to 'peek' a thumbnail, 0.01 USDC to verify IP provenance on-chain, and 0.01 USDC to download production-ready files. Perfect for luxury houses and independent designers building a programmable heritage. Why Hedera: By turning cataloging into a metered access protocol, the archive becomes a liquid asset. This prevents bulk scraping of design IP and ensures designers are compensated for every reference look-up or tech-pack pull. Market: TAM $3.2B — Global digital design management and product lifecycle logistics. | SAM $450M — The digital fashion assets and virtual goods market. | SOM $12M — Independent accessory designers and boutique labels requiring secure, micro-monetized IP vaults. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VaultLink" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Protect and monetize your digital archive. Every entry of high-fidelity accessory data (meshes, textures, tech specs) is encrypted. Users pay 0.01 USDC to 'peek' a thumbnail, 0.01 USDC to verify IP provenance on-chain, and 0.01 USDC to download production-ready files. Perfect for luxury houses and independent designers building a programmable heritage. Discipline: Fashion & Textile Design (digital accessory cataloging). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning cataloging into a metered access protocol, the archive becomes a liquid asset. This prevents bulk scraping of design IP and ensures designers are compensated for every reference look-up or tech-pack pull. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VaultLink" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-capsule-catalog-9-x402 Title: VogueVault · x402 Theme: Fashion & Textile Design (fashion) · seasonal collection storage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Designers publish seasonal lookbooks as cryptographically sealed IPFS manifests. Buyers, manufacturers, or retail scouts pay 0.01 USDC to unlock high-res patterns, tech packs, and provenance data. Every view is a micro-royalty, turning the archive into a metered asset. Why Hedera: By shifting from static storage to pay-per-view access, the designer monetizes the 'pre-release' or 'archival' interest. The x402 primitive acts as a minimal friction gateway for commercial intent. Market: TAM $3.4B — Global fashion IP management and digital asset licensing market. | SAM $185M — Independent fashion labels and boutique design houses adopting digital-first logistics. | SOM $12M — Web3-native luxury brands and early-adopters on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VogueVault" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Designers publish seasonal lookbooks as cryptographically sealed IPFS manifests. Buyers, manufacturers, or retail scouts pay 0.01 USDC to unlock high-res patterns, tech packs, and provenance data. Every view is a micro-royalty, turning the archive into a metered asset. Discipline: Fashion & Textile Design (seasonal collection storage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from static storage to pay-per-view access, the designer monetizes the 'pre-release' or 'archival' interest. The x402 primitive acts as a minimal friction gateway for commercial intent. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VogueVault" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-drape-documentation-10-x402 Title: Grainline · x402 Theme: Fashion & Textile Design (fashion) · fabric draping records Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-layer documentation for master-tailors and pattern makers. Lock high-fidelity 3D draping maneuvers, tension maps, and structural secrets behind 0.01 USDC micro-fees. Every view or 'unpin' of a technical drape sequence triggers an instant settlement to the designer, turning a studio archive into a metered technical resource for apprentices and manufacturers. Why Hedera: Transforming static records into a 'Pay-per-Look' technical library. By utilizing x402, the designer monetizes the granular expertise of fabric manipulation (the 'how-to') rather than just the final garment, creating a recursive revenue stream from design validation. Market: TAM $4.2B — Global fashion tech and digital prototyping market where micro-transactions facilitate IP sharing between design and production. | SAM $850M — High-end couture ateliers, boutique design houses, and pattern-making schools shifting to digital archives. | SOM $12M — Independent bespoke tailors and technical designers on Hedera using granular pay-per-view documentation for student guidance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Grainline" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-layer documentation for master-tailors and pattern makers. Lock high-fidelity 3D draping maneuvers, tension maps, and structural secrets behind 0.01 USDC micro-fees. Every view or 'unpin' of a technical drape sequence triggers an instant settlement to the designer, turning a studio archive into a metered technical resource for apprentices and manufacturers. Discipline: Fashion & Textile Design (fabric draping records). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transforming static records into a 'Pay-per-Look' technical library. By utilizing x402, the designer monetizes the granular expertise of fabric manipulation (the 'how-to') rather than just the final garment, creating a recursive revenue stream from design validation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Grainline" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-print-provenance-11-x402 Title: Pattern Pass · x402 Theme: Fashion & Textile Design (fashion) · digital print design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Textile studios monetize digital motifs via pay-per-view high-res source files. Designers pay 0.01 USDC to unlock an encrypted, watermark-free vector or seamless repeat for moodboarding or sampling. Each unlock generates a Hedera transaction id, serving as an immutable, time-stamped license for the print's usage rights in production. Why Hedera: Shifts digital print design from a static 'protection' model to a high-velocity 'metered access' model where provenance is proven through the transaction ledger itself. Market: TAM $4.8B — Global digital textile printing and licensing market. | SAM $450M — Independent textile designers and boutique fashion houses utilizing digital-first sourcing. | SOM $12M — Early adopters in the crypto-native luxury and 'phygital' fashion space on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Pattern Pass" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Textile studios monetize digital motifs via pay-per-view high-res source files. Designers pay 0.01 USDC to unlock an encrypted, watermark-free vector or seamless repeat for moodboarding or sampling. Each unlock generates a Hedera transaction id, serving as an immutable, time-stamped license for the print's usage rights in production. Discipline: Fashion & Textile Design (digital print design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts digital print design from a static 'protection' model to a high-velocity 'metered access' model where provenance is proven through the transaction ledger itself. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Pattern Pass" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-ecofabric-ledger-12-x402 Title: GreenThread · x402 Theme: Fashion & Textile Design (fashion) · sustainable textile tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-resolution provenance layer for sustainable textiles. $0.01 USDC to mint a permanent, verifiable batch record or to query a fabric's deep-tier environmental audit. Designers pay per lookup to verify GOTS/GRS certifications, preventing greenwashing at the pattern-cutting table. Brands pay per batch to register supply chain data, creating a machine-readable 'green' identity for raw materials. Why Hedera: By shifting from a subscription SaaS to a $0.01 per-query model, the app allows small-scale designers and AI-driven procurement tools to verify textile ethics without overhead. Payment acts as the audit trail; every transaction hash is the proof of verification. Market: TAM $2.1B — Global eco-friendly textile tracking and supply chain compliance reporting. | SAM $120M — Verifiable circular fashion markets and high-end sustainable material sourcing. | SOM $14M — Independent sustainable brands and boutique textile mills requiring low-friction certification verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GreenThread" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-resolution provenance layer for sustainable textiles. $0.01 USDC to mint a permanent, verifiable batch record or to query a fabric's deep-tier environmental audit. Designers pay per lookup to verify GOTS/GRS certifications, preventing greenwashing at the pattern-cutting table. Brands pay per batch to register supply chain data, creating a machine-readable 'green' identity for raw materials. Discipline: Fashion & Textile Design (sustainable textile tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a subscription SaaS to a $0.01 per-query model, the app allows small-scale designers and AI-driven procurement tools to verify textile ethics without overhead. Payment acts as the audit trail; every transaction hash is the proof of verification. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GreenThread" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-pattern-pathway-13-x402 Title: Motif · x402 Theme: Fashion & Textile Design (fashion) · seamless pattern sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Seamless repeat pattern files metered by usage. Pay 0.01 USDC per SVG node export or tile generation. High-fidelity textile assets accessible via HTS transfer signatures, ensuring designers are paid for every single derivative swatch generated in a manufacturing pipeline. Why Hedera: Traditional licensing is clunky for fast-fashion prototyping. x402 enables 'pay-per-swatch' micro-licensing, allowing AI fashion agents and digital tailors to pull unique patterns without lump-sum upfront costs. Market: TAM $3.8B — Global CAD software market for fashion and technical textiles. | SAM $420M — Digital textile assets and 3D garment simulation marketplaces. | SOM $12M — Independent textile designers and on-demand print shops using automated workflows. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Motif" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Seamless repeat pattern files metered by usage. Pay 0.01 USDC per SVG node export or tile generation. High-fidelity textile assets accessible via HTS transfer signatures, ensuring designers are paid for every single derivative swatch generated in a manufacturing pipeline. Discipline: Fashion & Textile Design (seamless pattern sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is clunky for fast-fashion prototyping. x402 enables 'pay-per-swatch' micro-licensing, allowing AI fashion agents and digital tailors to pull unique patterns without lump-sum upfront costs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Motif" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-avant-atlas-14-x402 Title: Avant Atlas · x402 Theme: Fashion & Textile Design (fashion) · experimental design archives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An immutable cryptographic archive for experimental fashion patterns and avant-garde process logs. Designers monetize their 'failed' experiments and radical prototypes by charging peer-designers or AI-fabrication agents 0.01 USDC to view high-res construction details, textile specs, or drape simulations. Payment generates a permanent on-chain citation of the design lineage. Why Hedera: Fashion archives are historically gated or proprietary. By atomizing access to the 'blueprint' level, Avant Atlas turns documentation into a revenue-generating research library where every high-fidelity zoom or pattern-download is a micro-transaction. Market: TAM $2.8B — Global fashion R&D and digital apparel archives moving toward open-access but monetized structures. | SAM $450M — The digital design assets and 3D garment modeling market for independent studios. | SOM $12M — Experimental couturiers and textile researchers utilizing Base for intellectual property provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Avant Atlas" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An immutable cryptographic archive for experimental fashion patterns and avant-garde process logs. Designers monetize their 'failed' experiments and radical prototypes by charging peer-designers or AI-fabrication agents 0.01 USDC to view high-res construction details, textile specs, or drape simulations. Payment generates a permanent on-chain citation of the design lineage. Discipline: Fashion & Textile Design (experimental design archives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Fashion archives are historically gated or proprietary. By atomizing access to the 'blueprint' level, Avant Atlas turns documentation into a revenue-generating research library where every high-fidelity zoom or pattern-download is a micro-transaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Avant Atlas" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fabric-fusion-15-x402 Title: StitchGraph · x402 Theme: Fashion & Textile Design (fashion) · mixed media textile records Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Access high-resolution macro-scans and procedural stitch-maps of mixed media textiles. Users pay per 'unravel' to view layered construction data, while creators earn instant USDC royalties for every design reference accessed by digital fashion designers and AI training models. Why Hedera: By turning physical textile records into pay-per-view digital assets, the app solves the 'open-source plagiarism' issue in fashion. x402 allows designers to monetize the process, not just the finished garment, by metering access to their proprietary texture techniques. Market: TAM $1.8B — The global textile design and technical apparel simulation industry. | SAM $95M — The digital fashion and virtual goods market, specifically 3D texture mapping and procedural material libraries. | SOM $4.2M — Independent textile artists and sustainable fashion innovators using Base for provenance and micropayment licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StitchGraph" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Access high-resolution macro-scans and procedural stitch-maps of mixed media textiles. Users pay per 'unravel' to view layered construction data, while creators earn instant USDC royalties for every design reference accessed by digital fashion designers and AI training models. Discipline: Fashion & Textile Design (mixed media textile records). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning physical textile records into pay-per-view digital assets, the app solves the 'open-source plagiarism' issue in fashion. x402 allows designers to monetize the process, not just the finished garment, by metering access to their proprietary texture techniques. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StitchGraph" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-wearable-wallet-16-x402 Title: ThreadState · x402 Theme: Fashion & Textile Design (fashion) · digital wardrobe management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered digital inventory protocol where users pay 0.01 USDC to mint, update, or retrieve high-fidelity metadata for individual garments. Every interaction—from logging a luxury acquisition to verifying an item's provenance for resale—is a micro-settled transaction on Hedera. By treating wardrobe data as a paid utility, users ensure immutable storage and verifiable digital twin ownership, while designers monetize per-view access to exclusive styling metadata. Why Hedera: By turning inventory management into a pay-per-use primitive, the app eliminates subscription fatigue and aligns cost directly with wardrobe size and engagement. x402 allows for granular 'authenticity checks' and 'styling unlocks' that feel like seamless, low-friction interactions. Market: TAM $12.5B — Total addressable market for digital fashion, circular economy tracking, and luxury provenance services. | SAM $1.8B — Global digital clothing and virtual fitting market, specifically focusing on mobile-first fashion enthusiasts and resellers. | SOM $45M — On-chain fashion collectors and Base ecosystem users managing physical/digital hybrid collections. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadState" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered digital inventory protocol where users pay 0.01 USDC to mint, update, or retrieve high-fidelity metadata for individual garments. Every interaction—from logging a luxury acquisition to verifying an item's provenance for resale—is a micro-settled transaction on Hedera. By treating wardrobe data as a paid utility, users ensure immutable storage and verifiable digital twin ownership, while designers monetize per-view access to exclusive styling metadata. Discipline: Fashion & Textile Design (digital wardrobe management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning inventory management into a pay-per-use primitive, the app eliminates subscription fatigue and aligns cost directly with wardrobe size and engagement. x402 allows for granular 'authenticity checks' and 'styling unlocks' that feel like seamless, low-friction interactions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadState" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-costume-chronicle-17-x402 Title: ThreadTrace · x402 Theme: Fashion & Textile Design (fashion) · theatrical costume records Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Every stitch has a history, and every look has a price. Archive high-resolution theatrical costume bibles and technical flats behind an HTS transfer paywall. Productions pay 0.01 USDC to unlock a specific character's design record or pattern detail, ensuring designers are compensated per reference look. Seamlessly settle rights-holder royalties every time a touring company or film scout accesses the archival specs. Pay-per-view patterns for the next generation of stagecraft. Why Hedera: Shifts traditional static archiving to a 'pay-per-reference' model. By metering access to production bibles, costume designers turn their back-catalog into a streaming-style royalty stream where every departmental lookup triggers a micro-settlement. Market: TAM $2.1B — The global theatrical and film production services market, shifting toward digital asset management and automated IP rights. | SAM $450M — Based on global theatrical production budgets and licensing fees for costume rentals and intellectual property. | SOM $12M — The niche market for professional costume designers and archival houses on Hedera looking to automate credit and royalty tracking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Every stitch has a history, and every look has a price. Archive high-resolution theatrical costume bibles and technical flats behind an HTS transfer paywall. Productions pay 0.01 USDC to unlock a specific character's design record or pattern detail, ensuring designers are compensated per reference look. Seamlessly settle rights-holder royalties every time a touring company or film scout accesses the archival specs. Pay-per-view patterns for the next generation of stagecraft. Discipline: Fashion & Textile Design (theatrical costume records). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts traditional static archiving to a 'pay-per-reference' model. By metering access to production bibles, costume designers turn their back-catalog into a streaming-style royalty stream where every departmental lookup triggers a micro-settlement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-style-snapshot-18-x402 Title: LOOKBOOK · x402 Theme: Fashion & Textile Design (fashion) · fashion influencer galleries Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity lookup engine for influencer metadata where every 'full-res' view or 'brand-tag' reveal triggers a 0.01 USDC settlement. Move past static moodboards into a paid-per-peek stream where creators earn directly as designers analyze their aesthetics. No subscriptions, just micro-cost trend forensics. Why Hedera: Current galleries are ad-laden or hidden behind $200/mo enterprise SaaS. x402 allows designers to pay for exactly the data they consume (e.g., specific fabric tags or SKU links) while providing influencers a direct-to-wallet micro-residual for their IP. Market: TAM $18B — Global influencer marketing and fashion metadata services. | SAM $850M — The digital fashion forecasting and trend analysis market. | SOM $12M — Independent textile designers and boutique brand owners utilizing pay-per-query tools for rapid moodboarding. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LOOKBOOK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity lookup engine for influencer metadata where every 'full-res' view or 'brand-tag' reveal triggers a 0.01 USDC settlement. Move past static moodboards into a paid-per-peek stream where creators earn directly as designers analyze their aesthetics. No subscriptions, just micro-cost trend forensics. Discipline: Fashion & Textile Design (fashion influencer galleries). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current galleries are ad-laden or hidden behind $200/mo enterprise SaaS. x402 allows designers to pay for exactly the data they consume (e.g., specific fabric tags or SKU links) while providing influencers a direct-to-wallet micro-residual for their IP. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LOOKBOOK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-thread-token-19-x402 Title: FiberGate · x402 Theme: Fashion & Textile Design (fashion) · yarn and thread digital catalogs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Access the encrypted spectral data and dye-lot composition for high-end yarns. Brands pay-per-pull to integrate exact thread color codes into digital twin designs, ensuring physical manufacturing matches 3D renders. Suppliers receive instant settlement whenever a designer 'pings' a spool for their digital mood board. Why Hedera: Traditional catalogs are static PDFs or expensive subscriptions. By atomizing access to specific yarn specs (twist, fiber mix, hex code), you enable a pay-as-you-design model that bridges the gap between digital fashion houses and physical textile mills. Market: TAM $14B — The global textile sourcing and supply chain management industry moving toward digital-first procurement. | SAM $280M — The digital fashion and virtual try-on market requiring high-fidelity textile metadata. | SOM $12M — Specialized luxury thread manufacturers and independent digital fashion creators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FiberGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Access the encrypted spectral data and dye-lot composition for high-end yarns. Brands pay-per-pull to integrate exact thread color codes into digital twin designs, ensuring physical manufacturing matches 3D renders. Suppliers receive instant settlement whenever a designer 'pings' a spool for their digital mood board. Discipline: Fashion & Textile Design (yarn and thread digital catalogs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional catalogs are static PDFs or expensive subscriptions. By atomizing access to specific yarn specs (twist, fiber mix, hex code), you enable a pay-as-you-design model that bridges the gap between digital fashion houses and physical textile mills. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FiberGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fashion-footprint-20-x402 Title: STITCH-TRACE · x402 Theme: Fashion & Textile Design (fashion) · design lifecycle tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized provenance ledger for high-end garments. Pay 0.01 USDC to append or verify a lifecycle event (fabric sourcing, dye lab tests, sewing completion) to a design's immutable chain of custody. Turn 'Made in Italy' from a label into a verifiable, audit-by-call timeline. Why Hedera: By turning supply chain updates into granular pay-per-event transactions, brands move from nebulous 'sustainability claims' to a metered, cryptographically signed ledger. x402 ensures that only authorized entities (suppliers, ateliers) can update the record for a nominal fee, preventing data spam and ensuring high-integrity tracking. Market: TAM $3.1B — The global fashion traceability and logistics market. | SAM $120M — Emerging sustainable fashion brands and luxury houses requiring transparent supply chains. | SOM $4.5M — Independent designers on Hedera using on-chain metadata for product authentication. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STITCH-TRACE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized provenance ledger for high-end garments. Pay 0.01 USDC to append or verify a lifecycle event (fabric sourcing, dye lab tests, sewing completion) to a design's immutable chain of custody. Turn 'Made in Italy' from a label into a verifiable, audit-by-call timeline. Discipline: Fashion & Textile Design (design lifecycle tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning supply chain updates into granular pay-per-event transactions, brands move from nebulous 'sustainability claims' to a metered, cryptographically signed ledger. x402 ensures that only authorized entities (suppliers, ateliers) can update the record for a nominal fee, preventing data spam and ensuring high-integrity tracking. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STITCH-TRACE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-pattern-nft-forge-21-x402 Title: Seamless · x402 Theme: Fashion & Textile Design (fashion) · blockchain pattern minting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless design engine where global textile looms and digital fashion houses pay 0.01 USDC to procedurally generate and license vector-perfect patterns. Instead of minting a static NFT collection and hoping for a sale, 'Seamless' operates as a high-frequency design utility: designers charge an 'at-the-source' fee for every weave-ready swatch generated. Every HTS transfer signature facilitates an instant usage rights transfer, creating a micro-licensing layer for the trillion-dollar apparel industry. Why Hedera: Existing NFT models for fashion suffer from low liquidity and high gas friction. By switching to a pay-per-pattern model, the app captures value at the moment of creation/export rather than relying on secondary resale. It shifts the paradigm from 'Digital Collectible' to 'Industrial Input.' Market: TAM $3.2B — The global smart-textile and automated apparel manufacturing industry. | SAM $140M — The digital textile design and CAD software market, specifically for independent creators and boutique labels. | SOM $850k — Micro-licensing fees from on-chain fashion protocols and 'Phygital' streetwear brands using automated design pipelines. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Seamless" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless design engine where global textile looms and digital fashion houses pay 0.01 USDC to procedurally generate and license vector-perfect patterns. Instead of minting a static NFT collection and hoping for a sale, 'Seamless' operates as a high-frequency design utility: designers charge an 'at-the-source' fee for every weave-ready swatch generated. Every HTS transfer signature facilitates an instant usage rights transfer, creating a micro-licensing layer for the trillion-dollar apparel industry. Discipline: Fashion & Textile Design (blockchain pattern minting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Existing NFT models for fashion suffer from low liquidity and high gas friction. By switching to a pay-per-pattern model, the app captures value at the moment of creation/export rather than relying on secondary resale. It shifts the paradigm from 'Digital Collectible' to 'Industrial Input.' 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Seamless" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-digital-drapery-hub-22-x402 Title: Drape · x402 Theme: Fashion & Textile Design (fashion) · online fabric simulation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity textile physics engine where every draping simulation is a paid computation. Users pay 0.01 USDC to render a fabric's gravity, shear, and friction on a 3D avatar. Pro designers monetize their proprietary 'weaver' files by charging per download or per simulation run. No subscriptions—just pay-per-poly cloth physics for precise garment prototyping. Why Hedera: Moving from a 'hub' to a 'metered engine' turns heavy CPU/GPU simulation tasks into a revenue stream. By pricing the computation and the intellectual property (the fabric data) via x402, it prevents bulk scraping of textile research. Market: TAM $4.2B — The global 3D CAD and textile manufacturing software market transitioning to cloud-based, collaborative workstreams. | SAM $450M — The digital fashion and virtual prototyping market for independent designers and boutique studios. | SOM $12M — High-end digital tailors and Web3 fashion houses requiring verified, per-use textile physics. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Drape" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity textile physics engine where every draping simulation is a paid computation. Users pay 0.01 USDC to render a fabric's gravity, shear, and friction on a 3D avatar. Pro designers monetize their proprietary 'weaver' files by charging per download or per simulation run. No subscriptions—just pay-per-poly cloth physics for precise garment prototyping. Discipline: Fashion & Textile Design (online fabric simulation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a 'hub' to a 'metered engine' turns heavy CPU/GPU simulation tasks into a revenue stream. By pricing the computation and the intellectual property (the fabric data) via x402, it prevents bulk scraping of textile research. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Drape" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-ethical-edit-log-23-x402 Title: PROVENANCE · x402 Theme: Fashion & Textile Design (fashion) · design decision auditing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular provenance layer for high-end garments. Designers pay per 'stich-level' audit entry to anchor ethical sourcing proofs (GOTS certifications, fair-wage attestations) to a garment's digital twin. Brands settle per-call to build a non-repudiable ledger of design integrity. Why Hedera: By moving from a 'subscription audit' to a per-entry micro-settlement, small independent designers can prove ethical claims without massive overhead, while luxury houses utilize it to meter agent-led supply chain verification. Market: TAM $15.4B — Total Addressable Market for global fashion supply chain auditing and ESG reporting. | SAM $850M — The sustainable fashion compliance and traceability software market. | SOM $12M — Independent bespoke designers and 'slow fashion' brands on Hedera requiring verifiable ethical transparency. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PROVENANCE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular provenance layer for high-end garments. Designers pay per 'stich-level' audit entry to anchor ethical sourcing proofs (GOTS certifications, fair-wage attestations) to a garment's digital twin. Brands settle per-call to build a non-repudiable ledger of design integrity. Discipline: Fashion & Textile Design (design decision auditing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a 'subscription audit' to a per-entry micro-settlement, small independent designers can prove ethical claims without massive overhead, while luxury houses utilize it to meter agent-led supply chain verification. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PROVENANCE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-silhouette-sync-24-x402 Title: Pattern Proof · x402 Theme: Fashion & Textile Design (fashion) · design silhouette sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity CAD silhouette vault where designers pay 0.01 USDC to 'cut' (fork) a professional block or 'seam' (merge) metadata into a collaborative tech pack. Every structural iteration is an on-chain commit, ensuring pattern-makers are paid for the underlying geometry before a single stitch is sewn. Why Hedera: Moving from simple 'sharing' to 'micropayment-gated CAD blocks' creates a high-velocity marketplace where the primitive is the design foundation itself. x402 allows for granular licensing of shapes, where a designer pays per iteration/download rather than a heavy upfront seat license. Market: TAM $3.8B — Global apparel design and PLM (Product Lifecycle Management) software market. | SAM $450M — Independent fashion labels, digital boutique owners, and technical designers utilizing 3D/CAD workflows. | SOM $12M — Web3-native fashion houses and pattern-making collectives requiring verifiable design provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Pattern Proof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity CAD silhouette vault where designers pay 0.01 USDC to 'cut' (fork) a professional block or 'seam' (merge) metadata into a collaborative tech pack. Every structural iteration is an on-chain commit, ensuring pattern-makers are paid for the underlying geometry before a single stitch is sewn. Discipline: Fashion & Textile Design (design silhouette sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from simple 'sharing' to 'micropayment-gated CAD blocks' creates a high-velocity marketplace where the primitive is the design foundation itself. x402 allows for granular licensing of shapes, where a designer pays per iteration/download rather than a heavy upfront seat license. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Pattern Proof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fabric-storychain-0-x402 Title: ThreadTrace · x402 Theme: Fashion & Textile Design (fashion) · material provenance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A transparent provenance layer for premium textiles where designers pay per-scan to verify raw material origins and ethical compliance. Every query against the global fiber-registry triggers a 0.01 USDC event, minting a verifiable integrity-proof for the garment's metadata. Why Hedera: By shifting from a subscription model to a pay-per-verification model, small-scale sustainable brands can access Tier-1 supply chain data without overhead. Payment becomes the 'seal of authenticity' recorded on-chain. Market: TAM $2.8B — Global textile traceability and ethical fashion compliance market. | SAM $450M — Independent sustainable fashion brands and luxury boutique designers requiring verifiable ESG data. | SOM $12M — Early-adopter artisan labels on Hedera focused on regenerative wool and organic cotton supply chains. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A transparent provenance layer for premium textiles where designers pay per-scan to verify raw material origins and ethical compliance. Every query against the global fiber-registry triggers a 0.01 USDC event, minting a verifiable integrity-proof for the garment's metadata. Discipline: Fashion & Textile Design (material provenance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a subscription model to a pay-per-verification model, small-scale sustainable brands can access Tier-1 supply chain data without overhead. Payment becomes the 'seal of authenticity' recorded on-chain. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-moodboard-mint-1-x402 Title: VOGUE-LOGIQUE · x402 Theme: Fashion & Textile Design (fashion) · collaborative curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A collaborative curation engine where designers pay 0.01 USDC to 'pin' high-fidelity assets or 'pull' curated palettes into their workspaces. x402 handles the granular royalty distribution, ensuring every moodboard contributor is paid instantly when their inspiration is used in a final design spec. Payment is the permission: no subscription, just pay-per-pin. Why Hedera: By moving from 'onchain credits' to raw x402 micropayments, the app eliminates the friction of bulk-buying tokens. It turns a moodboard into a live liquidity pool for aesthetic intelligence, where AI stylists and human designers trade pixels for pennies. Market: TAM $3.8B — Global creative curation and visual discovery platforms shifting toward micro-monetized IP. | SAM $450M — The digital fashion design and collaborative software market. | SOM $12M — Independent textile designers and boutique studios utilizing Base for efficient asset sourcing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VOGUE-LOGIQUE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A collaborative curation engine where designers pay 0.01 USDC to 'pin' high-fidelity assets or 'pull' curated palettes into their workspaces. x402 handles the granular royalty distribution, ensuring every moodboard contributor is paid instantly when their inspiration is used in a final design spec. Payment is the permission: no subscription, just pay-per-pin. Discipline: Fashion & Textile Design (collaborative curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'onchain credits' to raw x402 micropayments, the app eliminates the friction of bulk-buying tokens. It turns a moodboard into a live liquidity pool for aesthetic intelligence, where AI stylists and human designers trade pixels for pennies. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VOGUE-LOGIQUE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-trendtoken-vault-2-x402 Title: TrendNode · x402 Theme: Fashion & Textile Design (fashion) · trend validation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Trend validation as a metered utility. Users pay $0.01 USDC to submit a trend forecast or verify a peer's spotting with on-chain proof. Designers pay per 'Batch Unlock' to access real-time, high-fidelity trend sentiment filtered by the x402 payment wall to eliminate noise and sybil-spam. Settles instantly to Hedera testnet via HTS transfer. Why Hedera: Standard free social voting suffers from low-effort 'click-farming.' By requiring a $0.01 micro-stake per verification, TrendNode ensures skin-in-the-game for curators and high-signal data for designers. The x402 payment primitive turns trend forecasting into a verifiable digital commodity. Market: TAM $3.2T — Global fashion and apparel manufacturing market. | SAM $840M — Global fashion forecasting software market (WGSN/Heuritech landscape). | SOM $12M — Web3-native designers, independent fabricators, and AI-driven fashion agents requiring real-time sentiment data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrendNode" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Trend validation as a metered utility. Users pay $0.01 USDC to submit a trend forecast or verify a peer's spotting with on-chain proof. Designers pay per 'Batch Unlock' to access real-time, high-fidelity trend sentiment filtered by the x402 payment wall to eliminate noise and sybil-spam. Settles instantly to Hedera testnet via HTS transfer. Discipline: Fashion & Textile Design (trend validation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Standard free social voting suffers from low-effort 'click-farming.' By requiring a $0.01 micro-stake per verification, TrendNode ensures skin-in-the-game for curators and high-signal data for designers. The x402 payment primitive turns trend forecasting into a verifiable digital commodity. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrendNode" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-patternshare-3-x402 Title: THREADLACE · x402 Theme: Fashion & Textile Design (fashion) · digital pattern exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered textile primitive where every 'Print' or 'Cut' command triggers a $0.01 settlement. Instead of bulk licenses, designers offer patterns as live streaming assets. Makers pay-per-extraction, ensuring designers earn per garment produced, while HTS transfer signatures act as the cryptographic proof of authenticity for high-end digital fashion labels. Why Hedera: Shifts the model from a static marketplace to a consumption-based utility. By charging per use (e.g., per PDF layer unlock or CNC path export), it eliminates the 'buy once, share everywhere' piracy problem, turning the pattern into a continuous revenue stream. Market: TAM $9.5B — The global digital apparel and smart manufacturing design market. | SAM $420M — The independent 'indie' sewing and 3D fashion design market moving toward digital-only distribution. | SOM $12M — Early adopter 'Phygital' fashion brands and bespoke automated manufacturing labs on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "THREADLACE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered textile primitive where every 'Print' or 'Cut' command triggers a $0.01 settlement. Instead of bulk licenses, designers offer patterns as live streaming assets. Makers pay-per-extraction, ensuring designers earn per garment produced, while HTS transfer signatures act as the cryptographic proof of authenticity for high-end digital fashion labels. Discipline: Fashion & Textile Design (digital pattern exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from a static marketplace to a consumption-based utility. By charging per use (e.g., per PDF layer unlock or CNC path export), it eliminates the 'buy once, share everywhere' piracy problem, turning the pattern into a continuous revenue stream. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "THREADLACE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-colorchain-palette-4-x402 Title: KROMA · x402 Theme: Fashion & Textile Design (fashion) · color provenance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Architects of the visual world pay 0.01 USDC to mint a cryptographically signed 'Color Deed.' Each time a brand, factory, or AI image generator pulls your specific HEX/Pantone/CMYK mix for production, the x402 protocol triggers an instant micro-royalty. Instead of loose palettes, you own a metered provenance layer for every drop of ink. Why Hedera: Color palettes are currently 'stolen' without friction. By making the lookup and export of color formulas a 0.01 USDC transaction, we turn palette discovery into a high-volume revenue stream for designers. x402 allows for global, trustless licensing at the pixel level. Market: TAM $1.2B — The global apparel and textile manufacturing industry requiring certified color consistency. | SAM $180M — The digital design asset and color management software market for independent studios. | SOM $4.2M — Professional textile designers and generative AI prompt engineers paying for 'Verified Origin' color seeds. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KROMA" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Architects of the visual world pay 0.01 USDC to mint a cryptographically signed 'Color Deed.' Each time a brand, factory, or AI image generator pulls your specific HEX/Pantone/CMYK mix for production, the x402 protocol triggers an instant micro-royalty. Instead of loose palettes, you own a metered provenance layer for every drop of ink. Discipline: Fashion & Textile Design (color provenance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Color palettes are currently 'stolen' without friction. By making the lookup and export of color formulas a 0.01 USDC transaction, we turn palette discovery into a high-volume revenue stream for designers. x402 allows for global, trustless licensing at the pixel level. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KROMA" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-wearable-royalties-5-x402 Title: STITCH · x402 Theme: Fashion & Textile Design (fashion) · digital fashion royalties Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Digital garments that bill for existence. A programmable textile layer where every avatar 'wear', high-fidelity render, or AR filter activation triggers an instant 0.01 USDC micro-royalty. Brands and designers share the stream in real-time. No subscriptions, just pay-per-pose. Why Hedera: Moving from bulk licensing to x402-metered usage solves the 'dead asset' problem. Creators are paid for the actual frequency of use, and developers can integrate thousands of high-end assets into virtual worlds without upfront costs. Market: TAM $4.2B — The total predicted creator economy for virtual wearables and inter-game identity. | SAM $850M — The addressable market for digital fashion assets within gaming and social metaverses. | SOM $12M — Transaction volume from independent digital tailors and luxury brands piloting AR 'try-on' fees. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STITCH" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Digital garments that bill for existence. A programmable textile layer where every avatar 'wear', high-fidelity render, or AR filter activation triggers an instant 0.01 USDC micro-royalty. Brands and designers share the stream in real-time. No subscriptions, just pay-per-pose. Discipline: Fashion & Textile Design (digital fashion royalties). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from bulk licensing to x402-metered usage solves the 'dead asset' problem. Creators are paid for the actual frequency of use, and developers can integrate thousands of high-end assets into virtual worlds without upfront costs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STITCH" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-runway-replay-6-x402 Title: Cloth & Code · x402 Theme: Fashion & Textile Design (fashion) · event recording Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity archival tool for the front row. Every shutter press or timestamped highlight is an on-chain event. 0.01 USDC triggers a cryptographic signature that watermarks the stream for the press, secures replay rights, or mints a 'Look' into your style repertoire. No credit cards, just instant capture. Why Hedera: Shifting from subscription-based media tools to per-timestamp micropayments allows freelance photographers and fashion buyers to pay only for the specific frames or looks they need to license for editorial use. Market: TAM $9B — The global event recording and media rights management industry. | SAM $850M — The digital rights and licensing market for global fashion weeks and high-end atelier reveals. | SOM $12M — Independent fashion journalists and digital curators on Hedera utilizing frictionless archival tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Cloth & Code" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity archival tool for the front row. Every shutter press or timestamped highlight is an on-chain event. 0.01 USDC triggers a cryptographic signature that watermarks the stream for the press, secures replay rights, or mints a 'Look' into your style repertoire. No credit cards, just instant capture. Discipline: Fashion & Textile Design (event recording). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from subscription-based media tools to per-timestamp micropayments allows freelance photographers and fashion buyers to pay only for the specific frames or looks they need to license for editorial use. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Cloth & Code" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-sartorial-social-7-x402 Title: Stitch Protocol · x402 Theme: Fashion & Textile Design (fashion) · designer networking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes networking layer where every 'Handshake' is a metered smart contract. Instead of passive browsing, designers pay 0.01 USDC to unlock a peer's private portfolio, propose a collaboration, or digitally sign a non-disclosure agreement. By attaching a micro-cost to outreach, the platform eliminates spam and ensures every connection is backed by a verified, on-chain intent. Designers earn instantly for their time, and brands pay-per-view for talent scouting. Why Hedera: By moving from 'gasless' to 'micro-paid,' we transform networking from a social activity into a professional commodity market. The x402 primitive treats a profile view or a DM as a billable unit of intellectual property access. Market: TAM $3.2B — The total addressable market for global B2B fashion sourcing and contract management. | SAM $850M — The global digital fashion and freelance design marketplace. | SOM $12M — Independent textile designers and boutique labs on Hedera using micro-settlement for IP protection. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stitch Protocol" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes networking layer where every 'Handshake' is a metered smart contract. Instead of passive browsing, designers pay 0.01 USDC to unlock a peer's private portfolio, propose a collaboration, or digitally sign a non-disclosure agreement. By attaching a micro-cost to outreach, the platform eliminates spam and ensures every connection is backed by a verified, on-chain intent. Designers earn instantly for their time, and brands pay-per-view for talent scouting. Discipline: Fashion & Textile Design (designer networking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'gasless' to 'micro-paid,' we transform networking from a social activity into a professional commodity market. The x402 primitive treats a profile view or a DM as a billable unit of intellectual property access. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stitch Protocol" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fabric-fraction-8-x402 Title: ThreadCount · x402 Theme: Fashion & Textile Design (fashion) · material micro-ownership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view loom. $0.01 unlocks high-res microscopic scans and procedural weave patterns of rare, archival textiles. Whether you are an AI training a fashion model or a designer sourcing a vintage knit, payment is the direct gate to the digital twin’s IP. No subscriptions, just micro-metered access to a global vault of material DNA. Why Hedera: By turning 'ownership' into 'access-per-use,' we move from the friction of legal fractionalization to the fluid utility of a digital material library. HTS transfer allows designers to pull weave data directly into their CAD software for a penny, creating a high-velocity revenue stream for textile archives. Market: TAM $2.8B — The global textile design and fabric sourcing economy migrating to digital-first workflows. | SAM $450M — The digital fashion and virtual goods sampling market. | SOM $12M — Specialized archival textile digitizers and high-end 3D apparel designers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadCount" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view loom. $0.01 unlocks high-res microscopic scans and procedural weave patterns of rare, archival textiles. Whether you are an AI training a fashion model or a designer sourcing a vintage knit, payment is the direct gate to the digital twin’s IP. No subscriptions, just micro-metered access to a global vault of material DNA. Discipline: Fashion & Textile Design (material micro-ownership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'ownership' into 'access-per-use,' we move from the friction of legal fractionalization to the fluid utility of a digital material library. HTS transfer allows designers to pull weave data directly into their CAD software for a penny, creating a high-velocity revenue stream for textile archives. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadCount" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-virtual-trychain-9-x402 Title: FitCheck · x402 Theme: Fashion & Textile Design (fashion) · try-on verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographic proof-of-fit engine. Users pay 0.05 USDC per high-fidelity render to verify garment draping against their anonymized body scan. Brands pay a recurring 0.01 USDC 'verification fee' per session to eliminate 'bracket ordering' returns. Every try-on is a paid, verifiable event that generates a unique HTS transfer signed claim of fit accuracy. Why Hedera: Current virtual try-ons are low-stakes and inaccurate. By attaching a micro-cost to the render, we shift the value from a 'fun filter' to a 'financial utility' that replaces the $15 cost of a return shipment with a $0.05 verification fee. Market: TAM $1.2B — The global apparel return logistics and virtual fit market. | SAM $420M — The digital fashion assets and virtual fitting room segment. | SOM $18M — High-end e-commerce labels on Hedera seeking to reduce 30%+ return rates through micropayment-gated verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FitCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographic proof-of-fit engine. Users pay 0.05 USDC per high-fidelity render to verify garment draping against their anonymized body scan. Brands pay a recurring 0.01 USDC 'verification fee' per session to eliminate 'bracket ordering' returns. Every try-on is a paid, verifiable event that generates a unique HTS transfer signed claim of fit accuracy. Discipline: Fashion & Textile Design (try-on verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current virtual try-ons are low-stakes and inaccurate. By attaching a micro-cost to the render, we shift the value from a 'fun filter' to a 'financial utility' that replaces the $15 cost of a return shipment with a $0.05 verification fee. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FitCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-accessory-auth-10-x402 Title: Origin · x402 Theme: Fashion & Textile Design (fashion) · jewelry provenance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity provenance engine for luxury accessories. Pay-per-look: 0.01 USDC to instantly verify a piece's origin, ownership history, and authenticity certificates. Brands earn a micropayment every time their heritage is validated in the secondary market or during high-stakes social verification. Protocol-level verification for the next generation of digital-physical collectors. Why Hedera: By turning provenance into a metered service, we replace slow manual appraisals with instant, pay-per-query cryptographic certainty. x402 allows for frictionless 'status checks' where the payment acts as the trust-anchor. Market: TAM $4.2B — The global luxury goods authentication and resale verification market. | SAM $120M — High-end jewelry and luxury watch resale markets requiring continuous authentication. | SOM $8M — On-chain fashion collectors and digital-physical retailers using Base for inventory. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Origin" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity provenance engine for luxury accessories. Pay-per-look: 0.01 USDC to instantly verify a piece's origin, ownership history, and authenticity certificates. Brands earn a micropayment every time their heritage is validated in the secondary market or during high-stakes social verification. Protocol-level verification for the next generation of digital-physical collectors. Discipline: Fashion & Textile Design (jewelry provenance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning provenance into a metered service, we replace slow manual appraisals with instant, pay-per-query cryptographic certainty. x402 allows for frictionless 'status checks' where the payment acts as the trust-anchor. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Origin" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-capsule-contract-11-x402 Title: Stitchgate · x402 Theme: Fashion & Textile Design (fashion) · collaborative capsule collections Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn textile design into a liquid API. Every swatch, pattern, and stitch uploaded to a shared moodboard is gated by an x402 sign-in. Designers pay 0.01 USDC to pull a vector from a collaborator's library or trigger an AI-upscale for a print. Revenue is instantly streamed to the original creator's wallet upon the 'Unlock to Print' action, turning every design asset into a micro-revenue stream rather than a static file. Why Hedera: Traditional fashion collab tools suffer from 'participation friction' and murky royalty splits. By metering access to high-res design assets at the atomic level (per swatch or per edit), we ensure designers are paid for their specific contributions to a collection in real-time, long before the physical garment is even manufactured. Market: TAM $3.2B — The global CAD and collaborative PLM (Product Lifecycle Management) software industry. | SAM $450M — The digital fashion design and asset management market for independent labels. | SOM $18M — Small-batch streetwear labels using collaborative Web3 stacks for guest-designer capsules. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stitchgate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn textile design into a liquid API. Every swatch, pattern, and stitch uploaded to a shared moodboard is gated by an x402 sign-in. Designers pay 0.01 USDC to pull a vector from a collaborator's library or trigger an AI-upscale for a print. Revenue is instantly streamed to the original creator's wallet upon the 'Unlock to Print' action, turning every design asset into a micro-revenue stream rather than a static file. Discipline: Fashion & Textile Design (collaborative capsule collections). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional fashion collab tools suffer from 'participation friction' and murky royalty splits. By metering access to high-res design assets at the atomic level (per swatch or per edit), we ensure designers are paid for their specific contributions to a collection in real-time, long before the physical garment is even manufactured. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stitchgate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-style-stake-12-x402 Title: TrendNode · x402 Theme: Fashion & Textile Design (fashion) · community style curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized moodboard where every 'look' curation is an on-chain vote. Users pay 0.01 USDC to endorse a style or add a garment to the seasonal lookbook. These micropayments aggregate into real-time trend bounties for the featured designers. Curation is no longer passive; it is a paid signal that creates a high-fidelity 'Proof of Style' for the fashion industry. Why Hedera: By replacing 'staking' with high-frequency 0.01 USDC micropayments, we remove the friction of long-term lockups and replace it with instant style-validation. Each interaction is a granular financial vote, turning trend curation into a micro-incentivized economy for both tastemakers and designers. Market: TAM $3.5B — Global fast-fashion market shifting toward digital-first curation and agentic trend discovery. | SAM $120M — Digital fashion enthusiasts and active web3 ecosystem participants. | SOM $14M — Independent designers and Gen-Z trend-hunters on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrendNode" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized moodboard where every 'look' curation is an on-chain vote. Users pay 0.01 USDC to endorse a style or add a garment to the seasonal lookbook. These micropayments aggregate into real-time trend bounties for the featured designers. Curation is no longer passive; it is a paid signal that creates a high-fidelity 'Proof of Style' for the fashion industry. Discipline: Fashion & Textile Design (community style curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing 'staking' with high-frequency 0.01 USDC micropayments, we remove the friction of long-term lockups and replace it with instant style-validation. Each interaction is a granular financial vote, turning trend curation into a micro-incentivized economy for both tastemakers and designers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrendNode" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fabric-swapchain-13-x402 Title: Yardage · x402 Theme: Fashion & Textile Design (fashion) · material bartering Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-liquid ledger for textile scraps and deadstock. Pay-per-entry to list high-fidelity fabric scans, and pay-per-match to unlock shipping credentials and fiber composition data. Every scrap is a tradeable asset, precision-metered to favor the micro-designer over the industrial landfill. Why Hedera: By turning listings and data-access into $0.01 micro-transactions, we eliminate the subscription barrier for independent designers while creating a high-velocity dataset of textile availability. The pay-per-unlock model ensures the facilitator only settles when a designer finds the exact material match. Market: TAM $4.5B — Global circular fashion economy and textile waste management infrastructure. | SAM $1.2B — Professional independent designers, boutique ateliers, and fashion students requiring high-end material access. | SOM $18M — Early adopters in the sustainable 'upcycling' niche and technical textile collectors on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Yardage" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-liquid ledger for textile scraps and deadstock. Pay-per-entry to list high-fidelity fabric scans, and pay-per-match to unlock shipping credentials and fiber composition data. Every scrap is a tradeable asset, precision-metered to favor the micro-designer over the industrial landfill. Discipline: Fashion & Textile Design (material bartering). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning listings and data-access into $0.01 micro-transactions, we eliminate the subscription barrier for independent designers while creating a high-velocity dataset of textile availability. The pay-per-unlock model ensures the facilitator only settles when a designer finds the exact material match. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Yardage" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-runway-reward-14-x402 Title: FrontRow · x402 Theme: Fashion & Textile Design (fashion) · event incentives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A geo-fenced curation layer for live runways. Attendees pay 0.01 USDC to 'Signal' (upvote) a specific look in real-time. Each Signal signs an HTS transfer transfer that instantly unlocks a high-res digital pattern file or an exclusive discount code for that specific garment, while simultaneously boosting the look's rank on the event's global leaderboard. Why Hedera: Traditional POAPs are passive; FrontRow turns participation into a metered feedback loop. By pricing the 'vote' at a microscopic level, it creates a high-velocity data stream for designers while providing instant tangible utility (the digital asset) to the attendee. Market: TAM $2.8B — The global event technology and live engagement market for high-end retail and apparel. | SAM $450M — The digital fashion and virtual goods market within the luxury sector. | SOM $18M — Targeted spend on digital engagement and VIP activation at Tier-1 Fashion Weeks (NYC, Paris, Milan). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrontRow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A geo-fenced curation layer for live runways. Attendees pay 0.01 USDC to 'Signal' (upvote) a specific look in real-time. Each Signal signs an HTS transfer transfer that instantly unlocks a high-res digital pattern file or an exclusive discount code for that specific garment, while simultaneously boosting the look's rank on the event's global leaderboard. Discipline: Fashion & Textile Design (event incentives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional POAPs are passive; FrontRow turns participation into a metered feedback loop. By pricing the 'vote' at a microscopic level, it creates a high-velocity data stream for designers while providing instant tangible utility (the digital asset) to the attendee. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrontRow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-style-snapshot-15-x402 Title: ThreadCount · x402 Theme: Fashion & Textile Design (fashion) · digital outfit logging Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity wardrobe oracle. Users pay 0.01 USDC to 'mint-to-log' a daily fit, generating a permanent, tamper-proof record of their personal aesthetic evolution. The HTS transfer flow mirrors the friction of a 'double-tap' to like, but converts it into a micro-transactional inventory system. This kills low-effort spam and transforms a hobbyist outfit log into a verifiable provenance record for digital fashion assets and future resale value. Why Hedera: By moving from a 'free gas' model to a pay-per-log model, each entry gains immediate economic weight. It shifts the app from a passive gallery to a specialized archiving service for 'fit-check' enthusiasts and influencers who want to prove the age and authenticity of their wardrobe. Market: TAM $1.2B — The global 'circular economy' and digital fashion provenance market. | SAM $140M — The fashion-tech and wardrobe management app market, shifting toward decentralized ownership. | SOM $850K — Daily active fashion micro-influencers and hobbyists logging an average of 4-5 entries per month. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadCount" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity wardrobe oracle. Users pay 0.01 USDC to 'mint-to-log' a daily fit, generating a permanent, tamper-proof record of their personal aesthetic evolution. The HTS transfer flow mirrors the friction of a 'double-tap' to like, but converts it into a micro-transactional inventory system. This kills low-effort spam and transforms a hobbyist outfit log into a verifiable provenance record for digital fashion assets and future resale value. Discipline: Fashion & Textile Design (digital outfit logging). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a 'free gas' model to a pay-per-log model, each entry gains immediate economic weight. It shifts the app from a passive gallery to a specialized archiving service for 'fit-check' enthusiasts and influencers who want to prove the age and authenticity of their wardrobe. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadCount" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-craft-credit-16-x402 Title: LoomState · x402 Theme: Fashion & Textile Design (fashion) · artisan attribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Automate artisan royalties at the loom level. Designers pay 0.01 USDC to query the 'Master Weaver' registry to verify authenticity and append attribution to a digital twin. Every time a design file is accessed by a manufacturer or retailer, a micro-settlement is pushed to the original creator's wallet. Payment is the proof of origin. Why Hedera: x402 turns attribution from a passive label into an active bridge. By making the 'link' a paid call, we ensure the artisan is financially integrated into every downstream derivative work, creating a literal breadcrumb trail of paid loyalty. Market: TAM $3.5B — Global apparel logistics and artisan manufacturing, powered by automated micro-licensing for IP rights. | SAM $420M — The digital fashion and luxury provenance market, targeting brands requiring ESG and ethical sourcing verification. | SOM $18M — Independent textile designers and boutique heritage labels integrating x402-native PLM (Product Lifecycle Management) tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LoomState" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Automate artisan royalties at the loom level. Designers pay 0.01 USDC to query the 'Master Weaver' registry to verify authenticity and append attribution to a digital twin. Every time a design file is accessed by a manufacturer or retailer, a micro-settlement is pushed to the original creator's wallet. Payment is the proof of origin. Discipline: Fashion & Textile Design (artisan attribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: x402 turns attribution from a passive label into an active bridge. By making the 'link' a paid call, we ensure the artisan is financially integrated into every downstream derivative work, creating a literal breadcrumb trail of paid loyalty. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LoomState" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-pattern-provenance-17-x402 Title: STITCHGRAPH · x402 Theme: Fashion & Textile Design (fashion) · design copyright Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micropayment-gated library of premium sewing patterns where creators get paid every time a template is viewed or exported. By signing an HTS transfer authorization, users pay 0.01 USDC to unlock high-resolution vector data or verify the provenance hash of a design. No subscriptions; just pay-per-stitch infrastructure for the next generation of digital-to-physical fashion labels. Why Hedera: Moving from 'static record' (free/sub) to 'metered access' (x402) transforms copyright into a revenue-generating utility. The micropayment act acts as both the license fee and the immutable proof of access, creating a transparent audit trail for textile designers. Market: TAM $1.5B — Global digital pattern and hobbyist sewing market pivoting to decentralized IP verification. | SAM $120M — Individual designers and small boutique labels switching to pay-per-use licensing over high-cost CAD suites. | SOM $8M — Independent pattern makers on Hedera using micropayments for automated licensing of digital prints. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STITCHGRAPH" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micropayment-gated library of premium sewing patterns where creators get paid every time a template is viewed or exported. By signing an HTS transfer authorization, users pay 0.01 USDC to unlock high-resolution vector data or verify the provenance hash of a design. No subscriptions; just pay-per-stitch infrastructure for the next generation of digital-to-physical fashion labels. Discipline: Fashion & Textile Design (design copyright). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'static record' (free/sub) to 'metered access' (x402) transforms copyright into a revenue-generating utility. The micropayment act acts as both the license fee and the immutable proof of access, creating a transparent audit trail for textile designers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STITCHGRAPH" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-tailor-token-18-x402 Title: StitchFlow · x402 Theme: Fashion & Textile Design (fashion) · custom order management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Transforming bespoke tailoring into a high-throughput micro-service. Instead of clunky deposits, clients stream 0.01 USDC increments to unlock step-by-step progress updates, measurement validation, and fabric sourcing logs. Tailors use x402-metered requests to auto-verify supply chain authenticity. Every design tweak or signature stitch is a cryptographically signed, paid micro-commitment, maturing into a final NFT garment twin. Why Hedera: Moving custom garment work from risky lump-sum payments to granular, trustless progress-metering reduces the buyer's risk and the tailor's overhead. The x402 model turns the workflow into a series of paid 'micro-milestones'. Market: TAM $480B — Global custom-made and luxury apparel market shifting toward verifiable provenance and digital twin integration. | SAM $1.2B — High-end independent tailors and bespoke digital-physical (phygital) ateliers using on-chain tracking. | SOM $18M — Early-adopter custom clothiers on Hedera and Farcaster-integrated commerce bots. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StitchFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Transforming bespoke tailoring into a high-throughput micro-service. Instead of clunky deposits, clients stream 0.01 USDC increments to unlock step-by-step progress updates, measurement validation, and fabric sourcing logs. Tailors use x402-metered requests to auto-verify supply chain authenticity. Every design tweak or signature stitch is a cryptographically signed, paid micro-commitment, maturing into a final NFT garment twin. Discipline: Fashion & Textile Design (custom order management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving custom garment work from risky lump-sum payments to granular, trustless progress-metering reduces the buyer's risk and the tailor's overhead. The x402 model turns the workflow into a series of paid 'micro-milestones'. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StitchFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fashion-dao-hub-19-x402 Title: StitchGate · x402 Theme: Fashion & Textile Design (fashion) · decentralized fashion governance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A modular textile governance suite where Every design decision—from fabric selection to final swatch approval—is a non-custodial, micro-paid vote. Designers submit variations, and the collective 'Skin-in-the-Game' filters the signal through 0.01 USDC commits, preventing governance apathy and sybil attacks. Payment is the consensus mechanism: designers pay to propose, and voters pay to pivot. Why Hedera: By replacing free governance with x402 micropayments, we eliminate 'voter fatigue' and ensure every design iteration is backed by economic intent. This turns decentralized fashion from a slow debate into a high-velocity, metered production engine where the most committed aesthetics win. Market: TAM $1.7T — The global apparel and textile manufacturing industry transitioning toward digital-first, agile supply chains. | SAM $850M — The projected market for decentralized autonomous organizations (DAOs) and collaborative design tools within the fashion sector by 2027. | SOM $12M — Independent streetwear collectives and 'ghost-designer' networks on Hedera seeking low-friction, pay-per-poll governance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StitchGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A modular textile governance suite where Every design decision—from fabric selection to final swatch approval—is a non-custodial, micro-paid vote. Designers submit variations, and the collective 'Skin-in-the-Game' filters the signal through 0.01 USDC commits, preventing governance apathy and sybil attacks. Payment is the consensus mechanism: designers pay to propose, and voters pay to pivot. Discipline: Fashion & Textile Design (decentralized fashion governance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing free governance with x402 micropayments, we eliminate 'voter fatigue' and ensure every design iteration is backed by economic intent. This turns decentralized fashion from a slow debate into a high-velocity, metered production engine where the most committed aesthetics win. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StitchGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-vintage-vault-20-x402 Title: TrueThread · x402 Theme: Fashion & Textile Design (fashion) · authenticity verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-query verification engine for luxury resale. Users or AI-shopping agents pay 0.01 USDC to query the vault's high-fidelity textile database. Each micropayment triggers a Base-settled authenticity certificate, turning every 'legit check' into an instant, metered revenue stream for verified archivists. Why Hedera: Authenticity is currently a high-friction service. By atomizing the cost to $0.01 per check via x402, we enable high-velocity 'micro-appraisals' and allow AI aggregators to programmatically verify listings before they reach consumers. Market: TAM $4.5B — The global vintage and luxury resale market, increasingly reliant on automated provenance for cross-border trade. | SAM $850M — The secondary luxury market's annual spend on professional authentication services and digital twinning. | SOM $12M — The immediate market of high-frequency power sellers on platforms like Grailed and Depop requiring instant certificate generation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueThread" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-query verification engine for luxury resale. Users or AI-shopping agents pay 0.01 USDC to query the vault's high-fidelity textile database. Each micropayment triggers a Base-settled authenticity certificate, turning every 'legit check' into an instant, metered revenue stream for verified archivists. Discipline: Fashion & Textile Design (authenticity verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Authenticity is currently a high-friction service. By atomizing the cost to $0.01 per check via x402, we enable high-velocity 'micro-appraisals' and allow AI aggregators to programmatically verify listings before they reach consumers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueThread" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-style-swap-21-x402 Title: Threadlock · x402 Theme: Fashion & Textile Design (fashion) · peer-to-peer fashion exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-liquid garment provenance protocol where every 'offer' and 'shipment verification' is a 0.01 USDC primitive. Use x402 to automate escrow releases and verify authenticators. Instead of heavy subscriptions, users pay per-match and per-authentication-step, enabling a high-velocity, micro-gated circular economy. Why Hedera: Shifting from a free swap to a micro-payment model ensures high-intent participants. The 0.01 USDC fee acts as a spam filter for offers and a 'micro-escrow' fee for decentralized verification, making the protocol self-sustaining without platform fees. Market: TAM $120B — Total global circular fashion and peer-to-peer exchange economy. | SAM $450M — The addressable fashion-resale market currently looking for low-fee alternatives to Poshmark/Depop. | SOM $12M — Initial volume from power-swappers and luxury vintage collectors on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Threadlock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-liquid garment provenance protocol where every 'offer' and 'shipment verification' is a 0.01 USDC primitive. Use x402 to automate escrow releases and verify authenticators. Instead of heavy subscriptions, users pay per-match and per-authentication-step, enabling a high-velocity, micro-gated circular economy. Discipline: Fashion & Textile Design (peer-to-peer fashion exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from a free swap to a micro-payment model ensures high-intent participants. The 0.01 USDC fee acts as a spam filter for offers and a 'micro-escrow' fee for decentralized verification, making the protocol self-sustaining without platform fees. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Threadlock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-textile-tokenizer-22-x402 Title: Warp & Weft · x402 Theme: Fashion & Textile Design (fashion) · fabric asset digitization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Digitize high-resolution fabric textures and weave patterns into production-ready digital assets. Using x402, designers and manufacturers pay a 0.01 USDC micro-fee per high-fidelity render or metadata pull, enabling a pay-as-you-go textile library without hefty upfront licensing. Each scan is verified on Hedera, turning raw tactile data into metered digital IP. Why Hedera: Moving from a high-barrier 'tokenization' model to a fluid 'pay-per-pull' model allows smaller designers to access luxury textile archives. x402 handles the high-frequency settlement required when a designer cycles through 50+ textures in a single session. Market: TAM $14B — Global textile IP, manufacturing verification, and digital fashion licensing. | SAM $850M — The digital fashion twin and 3D garment rendering market. | SOM $12M — Independent digital fashion designers and boutique supply chain auditors on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Warp & Weft" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Digitize high-resolution fabric textures and weave patterns into production-ready digital assets. Using x402, designers and manufacturers pay a 0.01 USDC micro-fee per high-fidelity render or metadata pull, enabling a pay-as-you-go textile library without hefty upfront licensing. Each scan is verified on Hedera, turning raw tactile data into metered digital IP. Discipline: Fashion & Textile Design (fabric asset digitization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a high-barrier 'tokenization' model to a fluid 'pay-per-pull' model allows smaller designers to access luxury textile archives. x402 handles the high-frequency settlement required when a designer cycles through 50+ textures in a single session. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Warp & Weft" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-designer-direct-23-x402 Title: LoomState · x402 Theme: Fashion & Textile Design (fashion) · supply chain transparency Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A design-to-source protocol where every supply chain query—from checking organic yarn stock to auditing a low-impact dye house—is a 0.01 USDC micro-transaction. Designers pay builders for verified data, and suppliers charge for priority access to their specifications. No subscriptions, just atomic payments for provable transparency. Why Hedera: The supply chain is currently opaque and gatekept. By turning 'data requests' into x402 micropayments, we create a financial incentive for suppliers to keep real-time, high-integrity data. It eliminates the friction of enterprise SaaS for small independent designers. Market: TAM $5.2B — Global fashion sourcing and supply chain management software market. | SAM $450M — The emerging 'Transparent-to-Consumer' (T2C) and boutique luxury design market on Hedera. | SOM $12M — Initial cohort of 5,000 independent designers and 500 verified ethical textile mills. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LoomState" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A design-to-source protocol where every supply chain query—from checking organic yarn stock to auditing a low-impact dye house—is a 0.01 USDC micro-transaction. Designers pay builders for verified data, and suppliers charge for priority access to their specifications. No subscriptions, just atomic payments for provable transparency. Discipline: Fashion & Textile Design (supply chain transparency). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: The supply chain is currently opaque and gatekept. By turning 'data requests' into x402 micropayments, we create a financial incentive for suppliers to keep real-time, high-integrity data. It eliminates the friction of enterprise SaaS for small independent designers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LoomState" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-capsule-claim-24-x402 Title: ThreadSign · x402 Theme: Fashion & Textile Design (fashion) · limited drop authentication Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-metered authentication layer for physical luxury. Instead of a one-time static NFT, metadata access and provenance verification require a 0.01 USDC x402 signature. Brands meter the secondary market: every time a collector 'scans to prove' authenticity or transfers the digital twin, a micropayment settles instantly to the designer. Payment is the heartbeat of the chain of custody. Why Hedera: Shifts the model from a 'free scan' to a 'pay-per-claim' and 'pay-per-verify' utility. This eliminates bot-spamming of drop registrations and creates a recurring revenue stream for designers every time the garment's digital identity is queried. Market: TAM $450B — Global luxury goods market requiring immutable provenance and anti-counterfeit measures. | SAM $1.2B — The total addressable market for digital product passports (DPPs) and high-end streetwear resale verification. | SOM $18M — Targeted volume through independent 'drop' culture designers and boutique textile houses using Base for low-cost throughput. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadSign" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-metered authentication layer for physical luxury. Instead of a one-time static NFT, metadata access and provenance verification require a 0.01 USDC x402 signature. Brands meter the secondary market: every time a collector 'scans to prove' authenticity or transfers the digital twin, a micropayment settles instantly to the designer. Payment is the heartbeat of the chain of custody. Discipline: Fashion & Textile Design (limited drop authentication). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from a 'free scan' to a 'pay-per-claim' and 'pay-per-verify' utility. This eliminates bot-spamming of drop registrations and creates a recurring revenue stream for designers every time the garment's digital identity is queried. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadSign" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-thread-legacy-0-x402 Title: ThreadTrace · x402 Theme: Fashion & Textile Design (fashion) · fabric provenance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micropayment layer for fabric supply chains. Designers pay 0.01 USDC to instantly verify a bolt's origin, dye-cert, or labor audit on-chain. Each scan or 'provenance check' triggers an HTS transfer transfer, unlocking a cryptographically signed PDF certificate of authenticity. No subscription, just per-yard verification. Why Hedera: By replacing expensive, chunky certification fees with 0.01 USDC pings, small-batch sustainable designers can audit individual garments without overhead. The payment creates a verifiable 'heartbeat' for the fabric's journey. Market: TAM $2.5B — Global textile traceability and ethical fashion compliance markets. | SAM $450M — The sustainable apparel segment seeking low-friction blockchain transparency. | SOM $12M — Micro-boutiques and independent textile creators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micropayment layer for fabric supply chains. Designers pay 0.01 USDC to instantly verify a bolt's origin, dye-cert, or labor audit on-chain. Each scan or 'provenance check' triggers an HTS transfer transfer, unlocking a cryptographically signed PDF certificate of authenticity. No subscription, just per-yard verification. Discipline: Fashion & Textile Design (fabric provenance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing expensive, chunky certification fees with 0.01 USDC pings, small-batch sustainable designers can audit individual garments without overhead. The payment creates a verifiable 'heartbeat' for the fabric's journey. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-costume-chronicle-1-x402 Title: StitchLogic · x402 Theme: Fashion & Textile Design (fashion) · historical costume design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-resolution pattern vault for historical accurate costume design. Instead of subscriptions, designers pay 0.01 USDC to unlock specific vector patterns, high-res textile scans, or period-accurate construction schemas. Every 'view' or 'download' triggers a direct micropayment to the original researcher/costumier via their Magic Link email sign-in, creating a liquid market for rare historical garment data. Why Hedera: Transformation from a static gallery to a metered construction resource. By shifting to pay-per-pattern, it lowers the barrier for indie creators who only need one 18th-century sleeve draft, while providing instant liquidity to historians via x402's low-friction settlement. Market: TAM $4.2B — The global apparel design software and digital twin fashion industry moving toward on-chain provenance. | SAM $450M — The digital assets market for professional theatrical, film, and cozy-game costume department libraries. | SOM $12M — Independent historical reenactors, niche pattern makers, and digital fashion creators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StitchLogic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-resolution pattern vault for historical accurate costume design. Instead of subscriptions, designers pay 0.01 USDC to unlock specific vector patterns, high-res textile scans, or period-accurate construction schemas. Every 'view' or 'download' triggers a direct micropayment to the original researcher/costumier via their Magic Link email sign-in, creating a liquid market for rare historical garment data. Discipline: Fashion & Textile Design (historical costume design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transformation from a static gallery to a metered construction resource. By shifting to pay-per-pattern, it lowers the barrier for indie creators who only need one 18th-century sleeve draft, while providing instant liquidity to historians via x402's low-friction settlement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StitchLogic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-style-vault-2-x402 Title: DripFeed · x402 Theme: Fashion & Textile Design (fashion) · outfit curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to unlock an AI-curated outfit assembly based on your real-time calendar and weather data. No subscriptions; pay per look. Each 'look' is a signed metadata object you can port to digital closets or use to trigger shopping automations. Professional stylists earn streaming micropayments as users 'pull' their curated seasonal logic. Why Hedera: Transforms the static NFT portfolio into a high-frequency, utility-based metering system. By moving from 'owning a collection' to 'paying for a recommendation,' the app captures the daily repetitive habit of getting dressed. x402 allows for granular attribution where the original stylist gets paid every time a user triggers an outfit generation using their specific style-DNA. Market: TAM $24B — The global personalization-as-a-service market for e-commerce and retail. | SAM $1.2B — Professional styling services and personal shopper digital platforms. | SOM $85M — On-chain fashion enthusiasts and digital closet users on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DripFeed" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to unlock an AI-curated outfit assembly based on your real-time calendar and weather data. No subscriptions; pay per look. Each 'look' is a signed metadata object you can port to digital closets or use to trigger shopping automations. Professional stylists earn streaming micropayments as users 'pull' their curated seasonal logic. Discipline: Fashion & Textile Design (outfit curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transforms the static NFT portfolio into a high-frequency, utility-based metering system. By moving from 'owning a collection' to 'paying for a recommendation,' the app captures the daily repetitive habit of getting dressed. x402 allows for granular attribution where the original stylist gets paid every time a user triggers an outfit generation using their specific style-DNA. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DripFeed" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-pattern-provenance-3-x402 Title: Loom · x402 Theme: Fashion & Textile Design (fashion) · textile pattern design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless textile library where digital pattern files are locked behind x402 gates. Designers charge 0.01 USDC per high-res tile download or commercial use verification. Every 'Save to Swatchbook' or 'Export to Print' action triggers an HTS transfer signature, ensuring creators get paid instantly as their patterns are integrated into global manufacturing workflows. Why Hedera: By shifting from static registration to pay-per-use access, Pattern Provenance becomes a monetized API for the fashion industry. It transforms 'provenance' from a passive record into an active revenue stream for designers. Market: TAM $4.8B — The global textile design and high-tech manufacturing verification market. | SAM $140M — The digital textile printing market and independent pattern designer creator economy. | SOM $850K — Direct micropayment volume from boutique labels and prototype-stage apparel brands on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Loom" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless textile library where digital pattern files are locked behind x402 gates. Designers charge 0.01 USDC per high-res tile download or commercial use verification. Every 'Save to Swatchbook' or 'Export to Print' action triggers an HTS transfer signature, ensuring creators get paid instantly as their patterns are integrated into global manufacturing workflows. Discipline: Fashion & Textile Design (textile pattern design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from static registration to pay-per-use access, Pattern Provenance becomes a monetized API for the fashion industry. It transforms 'provenance' from a passive record into an active revenue stream for designers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Loom" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-colorcode-ledger-4-x402 Title: HueLock · x402 Theme: Fashion & Textile Design (fashion) · color study Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A digital color-mixing console where every 'eyedropper' extraction and palette generation requires a 0.01 USDC micro-settlement. Instead of static NFTs, designers pay per precise HEX/RGB derivation secured by the protocol. A unique HTS transfer signature locks the provenance of a specific shade to a designer's wallet, creating a cryptographically verifiable 'proof of discovery' for original textile hues. Metered color theory for the agentic fashion era. Why Hedera: By shifting from lumpy NFT mints to pay-per-extraction, we turn color exploration into a high-frequency, low-friction utility. The protocol ensures that every 'pick' is a recorded event on Hedera, effectively timestamping creative IP at the moment of inspiration for a fraction of a cent. Market: TAM $2.4B — The global textile design and color-standardization industry (Pantone alternatives). | SAM $85M — The digital textile and 3D fashion design software market. | SOM $4.2M — Independent streetwear designers and AI-driven fashion agents requiring unique color palettes. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "HueLock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A digital color-mixing console where every 'eyedropper' extraction and palette generation requires a 0.01 USDC micro-settlement. Instead of static NFTs, designers pay per precise HEX/RGB derivation secured by the protocol. A unique HTS transfer signature locks the provenance of a specific shade to a designer's wallet, creating a cryptographically verifiable 'proof of discovery' for original textile hues. Metered color theory for the agentic fashion era. Discipline: Fashion & Textile Design (color study). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from lumpy NFT mints to pay-per-extraction, we turn color exploration into a high-frequency, low-friction utility. The protocol ensures that every 'pick' is a recorded event on Hedera, effectively timestamping creative IP at the moment of inspiration for a fraction of a cent. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "HueLock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fabric-nft-atlas-5-x402 Title: THREADPATH · x402 Theme: Fashion & Textile Design (fashion) · textile sourcing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time sourcing engine for global artisanal textiles. Pay 0.01 USDC to unlock an verified producer's verified supply chain history, GPS coordinates, and direct procurement credentials. No subscriptions for boutique designers—just pay-per-lookup to bypass middlemen and verify ethical provenance instantly. Why Hedera: By turning provenance data into a metered micro-asset, we remove the barrier of expensive sourcing agencies. Designers pay only for the specific lead they need, and artisans receive a direct share of the query fee, incentivizing the onboarding of niche, regional data. Market: TAM $2.8B — The global textile sourcing and supply chain transparency market moving toward digital passports. | SAM $450M — Independent fashion brands and sustainable apparel startups sourcing ethical materials. | SOM $12M — The luxury boutique segment requiring on-chain proof of artisanal origin for ESG compliance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "THREADPATH" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time sourcing engine for global artisanal textiles. Pay 0.01 USDC to unlock an verified producer's verified supply chain history, GPS coordinates, and direct procurement credentials. No subscriptions for boutique designers—just pay-per-lookup to bypass middlemen and verify ethical provenance instantly. Discipline: Fashion & Textile Design (textile sourcing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning provenance data into a metered micro-asset, we remove the barrier of expensive sourcing agencies. Designers pay only for the specific lead they need, and artisans receive a direct share of the query fee, incentivizing the onboarding of niche, regional data. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "THREADPATH" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-design-chain-diary-6-x402 Title: StitchTrack · x402 Theme: Fashion & Textile Design (fashion) · fashion sketching Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A digital sketchbook where every stroke is a commit. Pay 0.01 USDC to 'Save & Seal' a sketch state to Base. Instead of a single final NFT, designers build a micro-payment audit trail that proves the evolution of an original garment, creating a verifiable 'proof of process' that protects against AI scraping and design theft. Why Hedera: By shifting from 'one-time minting' to 'per-save settlement,' the app creates a high-velocity utility for USDC. It captures the value of the creative process rather than just the final product. Market: TAM $96B — Global fashion design software and creative IP protection market. | SAM $2.8B — Independent fashion designers and digital illustrators using subscription-based CAD software. | SOM $14M — Web3-native designers and 'Phygital' creators requiring on-chain IP provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StitchTrack" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A digital sketchbook where every stroke is a commit. Pay 0.01 USDC to 'Save & Seal' a sketch state to Base. Instead of a single final NFT, designers build a micro-payment audit trail that proves the evolution of an original garment, creating a verifiable 'proof of process' that protects against AI scraping and design theft. Discipline: Fashion & Textile Design (fashion sketching). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'one-time minting' to 'per-save settlement,' the app creates a high-velocity utility for USDC. It captures the value of the creative process rather than just the final product. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StitchTrack" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-capsule-mint-7-x402 Title: ThreadCount · x402 Theme: Fashion & Textile Design (fashion) · collection drops Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Precision textile sourcing for the agentic era. Pay 0.01 USDC to cryptographically reveal a single high-resolution pattern, manufacturing spec, or digital twin asset. Instead of bulky mints, designers monetize the 'atomic look'—permitting AI stylists and boutique manufacturers to pay-per-view or pay-per-license for individual garment components. Exclusivity is maintained by a metered access wall where every zoom, download, and swatch-check is a micro-transaction settled on Hedera. Why Hedera: Shifts fashion from a 'buy the whole drop' model to 'pay for the specific creative asset.' It solves the friction of high-cost NFTs by making high-end design accessible but heavily metered, turning a lookbook into a high-frequency revenue stream. Market: TAM $3.5B — The global luxury fashion tech and digital-only apparel market. | SAM $450M — The emerging market for digital fashion skins, AI-generated textile patterns, and 'phygital' manufacturing blueprints. | SOM $12M — Independent streetwear designers and digital ateliers using x402 to gate seasonal collection assets on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadCount" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Precision textile sourcing for the agentic era. Pay 0.01 USDC to cryptographically reveal a single high-resolution pattern, manufacturing spec, or digital twin asset. Instead of bulky mints, designers monetize the 'atomic look'—permitting AI stylists and boutique manufacturers to pay-per-view or pay-per-license for individual garment components. Exclusivity is maintained by a metered access wall where every zoom, download, and swatch-check is a micro-transaction settled on Hedera. Discipline: Fashion & Textile Design (collection drops). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts fashion from a 'buy the whole drop' model to 'pay for the specific creative asset.' It solves the friction of high-cost NFTs by making high-end design accessible but heavily metered, turning a lookbook into a high-frequency revenue stream. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadCount" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-weave-witness-8-x402 Title: Loom Ledger · x402 Theme: Fashion & Textile Design (fashion) · handloom textiles Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-verification protocol for handloom authenticity. High-end fashion houses and boutique collectors pay 0.01 USDC to query the 'Loom Ledger' and instantly verify the yarn-count, artisan signature, and geo-location of a specific textile. Every successful verification triggers a micro-royalty back to the weaver's wallet via Base, turning 'Provenance' into a recurring revenue stream for the artisan. Why Hedera: Moves from a stagnant 'mint once' model to a 'pay-per-audit' model. By making verification a micropayment event, the app creates a continuous incentive for transparency in the luxury supply chain. Market: TAM $25B Global Textile Authentication and Anti-Counterfeiting market by 2030. | SAM $4.2B global market for authentic handloom and luxury artisanal textiles. | SOM $50M targeting high-end ethical fashion brands and luxury resale platforms (e.g., The RealReal) requiring Base-native provenance checks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Loom Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-verification protocol for handloom authenticity. High-end fashion houses and boutique collectors pay 0.01 USDC to query the 'Loom Ledger' and instantly verify the yarn-count, artisan signature, and geo-location of a specific textile. Every successful verification triggers a micro-royalty back to the weaver's wallet via Base, turning 'Provenance' into a recurring revenue stream for the artisan. Discipline: Fashion & Textile Design (handloom textiles). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves from a stagnant 'mint once' model to a 'pay-per-audit' model. By making verification a micropayment event, the app creates a continuous incentive for transparency in the luxury supply chain. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Loom Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-trend-tokenizer-9-x402 Title: TrendPulse · x402 Theme: Fashion & Textile Design (fashion) · fashion forecasting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time signal processing engine for fashion forecasting. Pay 0.01 USDC to unlock an atomic 'Trend Pulse'—a granular insight into emerging textile patterns, color shifts, or silhouette velocities. No subscriptions; pay only for the specific data points your collection needs. Designers and AI-driven supply chains query the forecasting engine per-use to programmatically adjust manufacturing runs based on live social and runway sentiment. Why Hedera: Moves forecasting from a static PDF/NFT gated model to a high-velocity, meterable data stream where payment triggers the insight delivery. Market: TAM $4.8B — The global fashion forecasting and market analysis industry moving toward algorithmic, real-time procurement. | SAM $250M — Fashion technology platforms and mid-market design houses integrating automated trend feeds. | SOM $12M — Independent digital fashion designers and automated supply chain agents on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrendPulse" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time signal processing engine for fashion forecasting. Pay 0.01 USDC to unlock an atomic 'Trend Pulse'—a granular insight into emerging textile patterns, color shifts, or silhouette velocities. No subscriptions; pay only for the specific data points your collection needs. Designers and AI-driven supply chains query the forecasting engine per-use to programmatically adjust manufacturing runs based on live social and runway sentiment. Discipline: Fashion & Textile Design (fashion forecasting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves forecasting from a static PDF/NFT gated model to a high-velocity, meterable data stream where payment triggers the insight delivery. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrendPulse" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fashion-dna-10-x402 Title: Origin Thread · x402 Theme: Fashion & Textile Design (fashion) · brand identity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A brand-protection API for high-end labels. Pay 0.01 USDC to hash a textile pattern or logo variant to the Chain of Origin. Authenticate a product's 'Design DNA' instantly at the point of sale or resale. This replaces expensive centralized certification brands with a pay-per-verification primitive, turning brand identity into a metered, immutable ledger. Why Hedera: By shifting from a one-time NFT mint to a per-verification micro-payment model, brands can monetize the secondary market's need for trust without heavy upfront fees. Each scan or 'DNA check' by a consumer or reseller is a high-velocity, low-cost x402 event. Market: TAM $3.4B — The global counterfeit prevention and brand protection market shifting to decentralized protocols. | SAM $450M — The luxury authentication and digital twin market for emerging Web3-native fashion houses. | SOM $12M — Transaction volume from independent streetwear labels and digital fashion boutiques on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Origin Thread" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A brand-protection API for high-end labels. Pay 0.01 USDC to hash a textile pattern or logo variant to the Chain of Origin. Authenticate a product's 'Design DNA' instantly at the point of sale or resale. This replaces expensive centralized certification brands with a pay-per-verification primitive, turning brand identity into a metered, immutable ledger. Discipline: Fashion & Textile Design (brand identity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a one-time NFT mint to a per-verification micro-payment model, brands can monetize the secondary market's need for trust without heavy upfront fees. Each scan or 'DNA check' by a consumer or reseller is a high-velocity, low-cost x402 event. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Origin Thread" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-upcycle-proof-11-x402 Title: STITCH-LOCK · x402 Theme: Fashion & Textile Design (fashion) · sustainable design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time verification layer for circular fashion. Pay 0.01 USDC to cryptographically seal the provenance of an upcycled garment, logging the transformation labor and material history. Every 'proof of rebirth' provides the creator an instant micro-settlement, turning sustainable labor into a liquid asset stream. Why Hedera: Shifts from a static NFT mint to a high-velocity attestation model where designers are paid per piece of supply chain evidence generated. Market: TAM $350B - The global second-hand and resale market requiring verifiable provenance. | SAM $4.2B - Global sustainable fashion influencers and boutique upcycling workshops. | SOM $85M - Base-native circular labels and digital product passport (DPP) early adopters. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STITCH-LOCK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time verification layer for circular fashion. Pay 0.01 USDC to cryptographically seal the provenance of an upcycled garment, logging the transformation labor and material history. Every 'proof of rebirth' provides the creator an instant micro-settlement, turning sustainable labor into a liquid asset stream. Discipline: Fashion & Textile Design (sustainable design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from a static NFT mint to a high-velocity attestation model where designers are paid per piece of supply chain evidence generated. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STITCH-LOCK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-accessory-archives-12-x402 Title: CaratCast · x402 Theme: Fashion & Textile Design (fashion) · jewelry design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-resolution jewelry blueprint vault for independent jewelers and boutique jewelers. Users sign a 0.01 USDC HTS transfer authorization to unlock one-time access to technical CAD files, high-fidelity renders, or material sourcing sheets. Pay-per-view provenance records allow buyers to verify the exact metallurgical history and artisan trail of a piece on-chain, eliminating the friction of full NFT sales for simple design inspiration or technical reference. Why Hedera: Current jewelry archives are locked behind expensive enterprise subscriptions or 'all-or-nothing' NFT sales. This reframes archival exploration as a micro-metered utility, where paying pennies per design-view creates a high-volume revenue stream for designers while protecting IP from bulk scraping. Market: TAM $6.5B — The global online jewelry market and digital IP licensing sector for high-end fashion. | SAM $240M — The digital design and pattern sharing market for independent luxury artisans and hobbyist jewelers. | SOM $1.8M — First-year volume from technical file unlocks for the growing community of 3D-printed jewelry designers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CaratCast" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-resolution jewelry blueprint vault for independent jewelers and boutique jewelers. Users sign a 0.01 USDC HTS transfer authorization to unlock one-time access to technical CAD files, high-fidelity renders, or material sourcing sheets. Pay-per-view provenance records allow buyers to verify the exact metallurgical history and artisan trail of a piece on-chain, eliminating the friction of full NFT sales for simple design inspiration or technical reference. Discipline: Fashion & Textile Design (jewelry design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current jewelry archives are locked behind expensive enterprise subscriptions or 'all-or-nothing' NFT sales. This reframes archival exploration as a micro-metered utility, where paying pennies per design-view creates a high-volume revenue stream for designers while protecting IP from bulk scraping. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CaratCast" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-moodboard-mint-13-x402 Title: VOGUEGATE · x402 Theme: Fashion & Textile Design (fashion) · concept curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless curation layer for high-fashion concepts. Pay 0.01 USDC to 'peek' at a curated moodboard, or stream micropayments to unlock high-res textile specs and pantone palettes. Designers earn instant settlement whenever an agency or AI image generator accesses their aesthetic IP for reference. Why Hedera: Shifts the value from 'static ownership' (NFTs) to 'access-based inspiration.' By gating the high-fidelity design data behind sub-cent payments, curated aesthetics become a liquid resource for other creators and agents. Market: TAM $24B — The global B2B trend forecasting and textile design industry. | SAM $850M — The digital design assets and stock photography market. | SOM $12M — Independent fashion consultants and concept artists utilizing micropayment gates for portfolio protection. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VOGUEGATE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless curation layer for high-fashion concepts. Pay 0.01 USDC to 'peek' at a curated moodboard, or stream micropayments to unlock high-res textile specs and pantone palettes. Designers earn instant settlement whenever an agency or AI image generator accesses their aesthetic IP for reference. Discipline: Fashion & Textile Design (concept curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the value from 'static ownership' (NFTs) to 'access-based inspiration.' By gating the high-fidelity design data behind sub-cent payments, curated aesthetics become a liquid resource for other creators and agents. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VOGUEGATE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fit-nft-lab-14-x402 Title: DrapeState · x402 Theme: Fashion & Textile Design (fashion) · virtual fitting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-fit API for virtual drape simulation. Instead of minting static files, users pay 0.01 USDC to calculate 'Micro-Drape' physics for any 3D garment against real-body parametric data. Brands integrate the endpoint to offer high-fidelity virtual try-ons where the cost is metered by the trial, not the purchase. Each successful HTS transfer signature generates a high-res render and a cryptographic 'Fit-Proof' receipt. Why Hedera: Shifts from the 'NFT as asset' model to 'Fit as a Service' (FaaS). By charging per simulation/unlock via x402, the app captures value from the high-frequency 'browsing' phase of fashion e-commerce rather than just the final sale. Market: TAM $40B — The global apparel e-commerce 'returns avoidance' market and the emerging metaverse wearable economy. | SAM $2.4B — The addressable market for virtual fitting room software and digital apparel retail integrations. | SOM $18M — Targeted reach through independent digital fashion labels and AI-stylist agents requiring automated fit validation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DrapeState" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-fit API for virtual drape simulation. Instead of minting static files, users pay 0.01 USDC to calculate 'Micro-Drape' physics for any 3D garment against real-body parametric data. Brands integrate the endpoint to offer high-fidelity virtual try-ons where the cost is metered by the trial, not the purchase. Each successful HTS transfer signature generates a high-res render and a cryptographic 'Fit-Proof' receipt. Discipline: Fashion & Textile Design (virtual fitting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from the 'NFT as asset' model to 'Fit as a Service' (FaaS). By charging per simulation/unlock via x402, the app captures value from the high-frequency 'browsing' phase of fashion e-commerce rather than just the final sale. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DrapeState" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-runway-record-15-x402 Title: LookBook · x402 Theme: Fashion & Textile Design (fashion) · showcase curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hi-def digital archive for luxury textiles where every 'Look' is an encrypted asset. Users pay 0.01 USDC to unlock an high-fidelity inspectable view, fabric makeup, and supply chain provenance. Instead of speculative NFT flipping, the value is in the 'View'—micropayments flow directly to the designers and photographers for every high-intent study of a garment's detail. Built for professional curators and pattern-makers who need instant, metered access to the global fashion record without subscriptions. Why Hedera: By shifting from the 'ownership' (NFT) model to 'access' (x402), the app captures continuous revenue from researchers and curators. Each HTS transfer signature facilitates an on-chain receipt for a single high-res extraction. Market: TAM $800B — The global apparel and luxury goods market transitioning toward digital-first provenance. | SAM $1.2B — Professional fashion design, archive services, and luxury curation industries. | SOM $45M — Niche digital archivists and fashion students on Hedera looking for low-friction research tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LookBook" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hi-def digital archive for luxury textiles where every 'Look' is an encrypted asset. Users pay 0.01 USDC to unlock an high-fidelity inspectable view, fabric makeup, and supply chain provenance. Instead of speculative NFT flipping, the value is in the 'View'—micropayments flow directly to the designers and photographers for every high-intent study of a garment's detail. Built for professional curators and pattern-makers who need instant, metered access to the global fashion record without subscriptions. Discipline: Fashion & Textile Design (showcase curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from the 'ownership' (NFT) model to 'access' (x402), the app captures continuous revenue from researchers and curators. Each HTS transfer signature facilitates an on-chain receipt for a single high-res extraction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LookBook" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-fabric-remix-16-x402 Title: ThreadHash · x402 Theme: Fashion & Textile Design (fashion) · textile remixing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized design engine where every modification to a textile pattern—color shifts, weave scaling, or motif overlay—costs 0.01 USDC. Payment instantly unlocks high-res exports and commits the creative lineage to the Base chain, ensuring the original artist and the modifier are cryptographically linked in the fabric's evolutionary tree. Why Hedera: By turning every 'remix' action into a micropayment, the app creates a high-velocity feedback loop for pattern designers while establishing a clear, paid-for provenance chain that prevents unauthorized scraping. Market: TAM $28B — The global digital textile printing and pattern design market. | SAM $450M — Independent textile designers and boutique fashion label creators moving toward digital-first workflows. | SOM $12M — On-chain fashion enthusiasts and remix culture participants on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadHash" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized design engine where every modification to a textile pattern—color shifts, weave scaling, or motif overlay—costs 0.01 USDC. Payment instantly unlocks high-res exports and commits the creative lineage to the Base chain, ensuring the original artist and the modifier are cryptographically linked in the fabric's evolutionary tree. Discipline: Fashion & Textile Design (textile remixing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning every 'remix' action into a micropayment, the app creates a high-velocity feedback loop for pattern designers while establishing a clear, paid-for provenance chain that prevents unauthorized scraping. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadHash" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-sustainable-stitch-17-x402 Title: PatternGrid · x402 Theme: Fashion & Textile Design (fashion) · zero-waste design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-click pattern generator for zero-waste garment construction. Instead of buying a design, designers pay 0.01 USDC to 'cut'—triggering an on-chain verification of fabric yield efficiency. Every geometric optimization call is a micro-transaction that settles the provenance of zero-waste integrity, turning the design process into a metered, verifiable audit trail. Why Hedera: Shifts from a static NFT mint to a usage-based design engine. Payment is the meter for the 'waste-reduction' computation, ensuring only optimized, paid-for patterns enter the supply chain. Market: TAM $1.8B — Global fashion design software and supply chain transparency sector. | SAM $120M — Digital pattern-making software and sustainable certification market. | SOM $8M — Independent zero-waste designers and boutique circular fashion houses requiring low-cost, high-frequency provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PatternGrid" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-click pattern generator for zero-waste garment construction. Instead of buying a design, designers pay 0.01 USDC to 'cut'—triggering an on-chain verification of fabric yield efficiency. Every geometric optimization call is a micro-transaction that settles the provenance of zero-waste integrity, turning the design process into a metered, verifiable audit trail. Discipline: Fashion & Textile Design (zero-waste design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from a static NFT mint to a usage-based design engine. Payment is the meter for the 'waste-reduction' computation, ensuring only optimized, paid-for patterns enter the supply chain. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PatternGrid" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-digital-drapes-18-x402 Title: DRAPE · x402 Theme: Fashion & Textile Design (fashion) · 3D garment design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Micro-metered physics for haute couture designers. Every time a user adjusts a drape parameter, renders a high-fidelity wrinkle simulation, or exports a pattern file for digital manufacturing, a 0.01 USDC micro-settlement occurs. Digital Drapes removes the subscription barrier for independent designers, allowing them to pay exactly for the compute-intensive physics steps they use. Proprietary garment simulations are locked behind x402 signatures, ensuring provenance and payment for every virtual fitting. Why Hedera: Shifting from a static NFT model to a pay-per-compute/pay-per-interaction model aligns with the high processing costs of 3D garment physics. It transforms 'browsing' into 'fitting' where every drape adjustment is a micro-transactional event. Market: TAM $1.8B — The global 3D CAD and textile design software market, pivoting toward agentic, automated garment generation. | SAM $450M — The digital fashion and virtual fitting room sector, focusing on independent designers and 3D studios. | SOM $12M — Initial capture of procedural pattern makers and digital-only apparel brands on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DRAPE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Micro-metered physics for haute couture designers. Every time a user adjusts a drape parameter, renders a high-fidelity wrinkle simulation, or exports a pattern file for digital manufacturing, a 0.01 USDC micro-settlement occurs. Digital Drapes removes the subscription barrier for independent designers, allowing them to pay exactly for the compute-intensive physics steps they use. Proprietary garment simulations are locked behind x402 signatures, ensuring provenance and payment for every virtual fitting. Discipline: Fashion & Textile Design (3D garment design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from a static NFT model to a pay-per-compute/pay-per-interaction model aligns with the high processing costs of 3D garment physics. It transforms 'browsing' into 'fitting' where every drape adjustment is a micro-transactional event. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DRAPE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-ethnic-essence-19-x402 Title: KENTE-SOURCE · x402 Theme: Fashion & Textile Design (fashion) · cultural textiles Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-definition patterns library where designers pay 0.01 USDC to unlock the vector source of authentic cultural weaves. Every 'view-full-res' call executes an on-chain royalty micropayment directly to the verified communal craft guild or artisan, creating a traceable, paid provenance for industrial textile reproduction. Why Hedera: Traditional NFT minting is too high-friction for design iterations. x402 enables 'pay-per-use' design inspiration, turning cultural preservation into a recurring micro-revenue stream for the original weavers. Market: TAM $13B — Global ethnic wear and textile heritage market. | SAM $280M — Independent fashion labels and digital apparel creators requiring high-fidelity, ethically sourced assets. | SOM $12M — Web3-native designers and 'Phygital' fashion brands on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KENTE-SOURCE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-definition patterns library where designers pay 0.01 USDC to unlock the vector source of authentic cultural weaves. Every 'view-full-res' call executes an on-chain royalty micropayment directly to the verified communal craft guild or artisan, creating a traceable, paid provenance for industrial textile reproduction. Discipline: Fashion & Textile Design (cultural textiles). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT minting is too high-friction for design iterations. x402 enables 'pay-per-use' design inspiration, turning cultural preservation into a recurring micro-revenue stream for the original weavers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KENTE-SOURCE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-accessory-authenticator-20-x402 Title: THE VAULT · x402 Theme: Fashion & Textile Design (fashion) · limited-edition accessories Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A design-leak protection layer for luxury accessory patterns. Designers deposit high-resolution technical sketches into an encrypted vault; viewers pay 0.05 USDC per 'Peep' to unlock a high-fidelity, time-limited preview. Each payment triggers a cryptographically signed provenance receipt on Hedera, verifying the viewer’s identity and intent, converting window shoppers into trackable leads. For limited releases, the 100th 'Peep' payment automatically triggers the minting of an exclusivity certificate, closing the vault forever. Why Hedera: By shifting from static NFT minting to a 'Pay-per-View' model, the designer monetizes the hype cycle itself, not just the final product, while using x402 to establish a chain of custody for intellectual property. Market: TAM $28B — Global anti-counterfeit packaging and product authentication market. | SAM $1.2B — Luxury retail verification and digital-twin authentication services. | SOM $85M — Independent accessory designers and boutique streetwear labels using Base/HashPack for drops. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "THE VAULT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A design-leak protection layer for luxury accessory patterns. Designers deposit high-resolution technical sketches into an encrypted vault; viewers pay 0.05 USDC per 'Peep' to unlock a high-fidelity, time-limited preview. Each payment triggers a cryptographically signed provenance receipt on Hedera, verifying the viewer’s identity and intent, converting window shoppers into trackable leads. For limited releases, the 100th 'Peep' payment automatically triggers the minting of an exclusivity certificate, closing the vault forever. Discipline: Fashion & Textile Design (limited-edition accessories). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from static NFT minting to a 'Pay-per-View' model, the designer monetizes the hype cycle itself, not just the final product, while using x402 to establish a chain of custody for intellectual property. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "THE VAULT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-craft-chain-connect-21-x402 Title: Loom Logic · x402 Theme: Fashion & Textile Design (fashion) · artisan collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Every stitch has a price. Artisans list high-fidelity textile patterns, weaving techniques, and dye recipes. Designers pay 0.01 USDC per 'view-and-verify' call to access full technical specs, ensuring fair micropayments reach the source artisan every time a creative asset is referenced for production. Use x402 to programmatically unlock high-resolution fabrication guides. Why Hedera: Shifts the model from a static NFT mint to a metered access protocol where artisans earn per-look or per-download, creating a sustainable stream for small-scale creators. Market: TAM $2.5B — global textile design and technical apparel documentation market. | SAM $450M — digital textile design and artisan-led niche markets. | SOM $12M — early adopters in the ethical fashion 'traceability' movement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Loom Logic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Every stitch has a price. Artisans list high-fidelity textile patterns, weaving techniques, and dye recipes. Designers pay 0.01 USDC per 'view-and-verify' call to access full technical specs, ensuring fair micropayments reach the source artisan every time a creative asset is referenced for production. Use x402 to programmatically unlock high-resolution fabrication guides. Discipline: Fashion & Textile Design (artisan collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from a static NFT mint to a metered access protocol where artisans earn per-look or per-download, creating a sustainable stream for small-scale creators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Loom Logic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-virtual-vogue-vault-22-x402 Title: Stitchory · x402 Theme: Fashion & Textile Design (fashion) · digital fashion archives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-resolution digital morgue for fashion researchers and designers. Pay 0.01 USDC to unlock an ultra-high-fidelity 3D scan, construction pattern, or historical metadata packet. Instead of bulk subscriptions or static NFTs, you pay only for the specific inspiration you reference in your new collection, creating a fluid, pay-per-view encyclopedia of textile history. Why Hedera: Moving away from static NFTs to a metered-access model turns a 'collection' into a 'utility.' Designers need snippets of history, not ownership of the whole archive. x402 enables granular, high-frequency access to proprietary design data without friction. Market: TAM $4.2B — Global fashion education, archive licensing, and digital twin industries. | SAM $850M — The digital design assets and stock 3D object industry for luxury brands. | SOM $12M — Independent digital fashion houses and students using Base for architectural design sourcing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stitchory" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-resolution digital morgue for fashion researchers and designers. Pay 0.01 USDC to unlock an ultra-high-fidelity 3D scan, construction pattern, or historical metadata packet. Instead of bulk subscriptions or static NFTs, you pay only for the specific inspiration you reference in your new collection, creating a fluid, pay-per-view encyclopedia of textile history. Discipline: Fashion & Textile Design (digital fashion archives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving away from static NFTs to a metered-access model turns a 'collection' into a 'utility.' Designers need snippets of history, not ownership of the whole archive. x402 enables granular, high-frequency access to proprietary design data without friction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stitchory" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-print-proofs-23-x402 Title: ThreadSeal · x402 Theme: Fashion & Textile Design (fashion) · fabric print designs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Protect and prove textile IP in real-time. 0.01 USDC triggers a cryptographically signed high-res watermarking and timestamping of your fabric pattern on Hedera. Designers pay per upload to anchor authorship; manufacturers pay per hi-res file retrieval to verify licensing rights. Payment is the proof. Why Hedera: By turning file retrieval and timestamping into micropayment events, the app creates a 'pay-per-verify' model that suits fast-fashion supply chains where manual contracts are too slow but IP protection is critical. Market: TAM $4.2B — The global digital textile printing market and intellectual property protection sector for apparel. | SAM $450M — Independent textile designers and boutique garment manufacturers using digital-first workflows. | SOM $12M — Emerging print designers and digital pattern houses on Hedera testnet validating patterns for the NFT-to-physical market. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadSeal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Protect and prove textile IP in real-time. 0.01 USDC triggers a cryptographically signed high-res watermarking and timestamping of your fabric pattern on Hedera. Designers pay per upload to anchor authorship; manufacturers pay per hi-res file retrieval to verify licensing rights. Payment is the proof. Discipline: Fashion & Textile Design (fabric print designs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning file retrieval and timestamping into micropayment events, the app creates a 'pay-per-verify' model that suits fast-fashion supply chains where manual contracts are too slow but IP protection is critical. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadSeal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA fashion-style-storyline-24-x402 Title: ThreadLine · x402 Theme: Fashion & Textile Design (fashion) · fashion storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A narrative engine for fashion brands where every chapter of a collection's lore—moodboards, fabric origins, and design philosophy—is metered. Users pay 0.01 USDC to unlock the next 'page' of a garment's digital history, turning a static lookbook into a paid storytelling experience. Brands earn per view, ensuring their creative process is monetized as much as the final product. Why Hedera: By shifting from 'all-or-nothing' NFT mints to pay-per-chapter access, the app lowers the barrier for consumers to interact with high-fashion lore while providing designers with immediate, high-frequency revenue from their research and development. Market: TAM $2.8B — The global digital fashion and luxury storytelling market, driven by the shift toward experiential digital ownership. | SAM $450M — The digital collectible and narrative media market for independent fashion labels and high-end streetware. | SOM $12M — Direct micro-revenue generated by early-adopter designers on Hedera using x402 to gate seasonal lookbook reveals. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ThreadLine" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A narrative engine for fashion brands where every chapter of a collection's lore—moodboards, fabric origins, and design philosophy—is metered. Users pay 0.01 USDC to unlock the next 'page' of a garment's digital history, turning a static lookbook into a paid storytelling experience. Brands earn per view, ensuring their creative process is monetized as much as the final product. Discipline: Fashion & Textile Design (fashion storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'all-or-nothing' NFT mints to pay-per-chapter access, the app lowers the barrier for consumers to interact with high-fashion lore while providing designers with immediate, high-frequency revenue from their research and development. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ThreadLine" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ============================================================================== THEME · Filmmaking & Animation filmmakers, animators, motion designers, storyboard artists ============================================================================== ------------------------------------------------------------------------------ IDEA film-animation-framechain-ledger-0-x402 Title: IMPRINT · x402 Theme: Filmmaking & Animation (film-animation) · frame provenance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A frame-by-frame provenance layer where animators pay-per-seal to cryptographically anchor work-in-progress frames to Base. Studios and agencies pay-per-verify (0.01 USDC) to audit the derivation path of any asset, ensuring zero unauthorized AI-injection or frame-snatching. Proof of authorship is now a metered utility. Why Hedera: Moving from a monolithic 'ledger' to a pay-per-frame 'seal' allows independent animators to notarize their labor incrementally. It converts provenance from an overhead cost into a granular protection service where each micro-transaction (HTS transfer) acts as a temporal timestamp. Market: TAM $12.5B — The global animation and VFX market, increasingly demanding verifiable provenance for AI-compliance and insurance. | SAM $850M — Focused on the boutique animation, VFX, and post-production houses transitioning to transparent supply chains. | SOM $14M — The early-adopter segment of freelance animators and indie studios on Hedera requiring affordable, per-frame IP protection. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "IMPRINT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A frame-by-frame provenance layer where animators pay-per-seal to cryptographically anchor work-in-progress frames to Base. Studios and agencies pay-per-verify (0.01 USDC) to audit the derivation path of any asset, ensuring zero unauthorized AI-injection or frame-snatching. Proof of authorship is now a metered utility. Discipline: Filmmaking & Animation (frame provenance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a monolithic 'ledger' to a pay-per-frame 'seal' allows independent animators to notarize their labor incrementally. It converts provenance from an overhead cost into a granular protection service where each micro-transaction (HTS transfer) acts as a temporal timestamp. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "IMPRINT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-scriptmint-1-x402 Title: ScriptMint · x402 Theme: Filmmaking & Animation (film-animation) · script rights tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn screenplays into metered assets. Producers pay $0.01 per page-read via HTS transfer, with instant USDC settlement to the writer. Unlock high-resolution downloads or character bibles for a micro-fee. Every access event is an on-chain receipt, creating an immutable paper trail of who has read the script and when—eliminating 'idea theft' disputes while providing writers with instant, granular revenue. Why Hedera: ScriptMint shifts from a static registry to a living, metered distribution engine. By charging per interaction, it filters for high-intent readers and provides a friction-less 'pay-per-read' model for the agent-driven era of content scouting. Market: TAM $950M — Global screenwriting and script development software market. | SAM $140M — The scripted content acquisition market for streaming platforms and indie studios. | SOM $12M — Pre-production script coverage and rights clearance for independent filmmakers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScriptMint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn screenplays into metered assets. Producers pay $0.01 per page-read via HTS transfer, with instant USDC settlement to the writer. Unlock high-resolution downloads or character bibles for a micro-fee. Every access event is an on-chain receipt, creating an immutable paper trail of who has read the script and when—eliminating 'idea theft' disputes while providing writers with instant, granular revenue. Discipline: Filmmaking & Animation (script rights tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: ScriptMint shifts from a static registry to a living, metered distribution engine. By charging per interaction, it filters for high-intent readers and provides a friction-less 'pay-per-read' model for the agent-driven era of content scouting. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScriptMint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animstake-voting-2-x402 Title: DirectorSign · x402 Theme: Filmmaking & Animation (film-animation) · crowd animation funding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn animation production into a real-time meritocracy. Animators upload character design variants or storyboard beats behind x402 gates. Supporters don't just 'vote'; they pay 0.01 USDC to unlock and sign off on specific creative directions. Each micropayment is a granular vote of confidence that instantly streams to the production wallet, enabling frame-by-frame funding where every creative pivot is validated by the crowd's capital. Why Hedera: Traditional crowdfunding is lumpy and high-friction. By using x402, the payment is the poll. It filters out sybil noise and provides animators with immediate, liquid feedback for every design choice, turning the audience into a distributed micro-executive producer. Market: TAM $3.8B — The global animation production and creator-economy funding sector. | SAM $450M — Addressing the independent animation and web-series market seeking alternatives to Patreon/Kickstarter. | SOM $12M — Early-adopter anime and indie shorts communities on Hedera and Farcaster. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DirectorSign" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn animation production into a real-time meritocracy. Animators upload character design variants or storyboard beats behind x402 gates. Supporters don't just 'vote'; they pay 0.01 USDC to unlock and sign off on specific creative directions. Each micropayment is a granular vote of confidence that instantly streams to the production wallet, enabling frame-by-frame funding where every creative pivot is validated by the crowd's capital. Discipline: Filmmaking & Animation (crowd animation funding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional crowdfunding is lumpy and high-friction. By using x402, the payment is the poll. It filters out sybil noise and provides animators with immediate, liquid feedback for every design choice, turning the audience into a distributed micro-executive producer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DirectorSign" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-motionnft-vault-3-x402 Title: Kinetic · x402 Theme: Filmmaking & Animation (film-animation) · motion asset NFTs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An on-chain library of high-fidelity cinema assets (Lottie, FBX, Alembic) where every preview is gated and every download is a $0.01 x402 stream. Stop selling bundles; start metering usage. Motion designers earn USDC every time an editor 'tries' an asset in their timeline. Payment is the licensing trigger. Why Hedera: Traditional asset stores suffer from high friction and piracy. x402 turns the 'Preview' and 'Import' actions into micro-transactions. This creates a high-velocity library where 'pay-per-frame' or 'pay-per-import' replaces $500 licenses, making pro assets accessible to hobbyists while providing creators with immediate, granular cash flow. Market: TAM $15B — The global 3D stock footage and motion graphics market. | SAM $1.2B — Professional editors and motion studios using Web3-enabled creative suites. | SOM $45M — Niche of crypto-native content creators and indie animators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An on-chain library of high-fidelity cinema assets (Lottie, FBX, Alembic) where every preview is gated and every download is a $0.01 x402 stream. Stop selling bundles; start metering usage. Motion designers earn USDC every time an editor 'tries' an asset in their timeline. Payment is the licensing trigger. Discipline: Filmmaking & Animation (motion asset NFTs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional asset stores suffer from high friction and piracy. x402 turns the 'Preview' and 'Import' actions into micro-transactions. This creates a high-velocity library where 'pay-per-frame' or 'pay-per-import' replaces $500 licenses, making pro assets accessible to hobbyists while providing creators with immediate, granular cash flow. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-storyboardchain-4-x402 Title: DraftSeal · x402 Theme: Filmmaking & Animation (film-animation) · storyboard authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An immutable visual audit trail for production houses. Pay 0.01 USDC to timestamp a frame, commit a version, or verify the 'First Look' cryptographic signature of a scene. Protects artists from AI-scraping claims by proving human-led evolution through a metered history of sketches. Why Hedera: Traditional copyright is slow; x402 makes attribution granular. By paying per 'Commit,' artists create a ledger of effort that proves originality before the final export. It turns the storyboard into a cryptographically verified proof-of-work. Market: TAM $180B — The global M&E industry's total spend on content protection and IP litigation. | SAM $850M — The independent animation and pre-visualization market moving to decentralized production pipelines. | SOM $12M — Series A-C animation studios using Base for real-time asset provenance and IP protection. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DraftSeal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An immutable visual audit trail for production houses. Pay 0.01 USDC to timestamp a frame, commit a version, or verify the 'First Look' cryptographic signature of a scene. Protects artists from AI-scraping claims by proving human-led evolution through a metered history of sketches. Discipline: Filmmaking & Animation (storyboard authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional copyright is slow; x402 makes attribution granular. By paying per 'Commit,' artists create a ledger of effort that proves originality before the final export. It turns the storyboard into a cryptographically verified proof-of-work. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DraftSeal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-licenselock-5-x402 Title: LicenseLock · x402 Theme: Filmmaking & Animation (film-animation) · license management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-frame licensing engine for animators. Instead of complex legal paperwork, motion assets (rigs, textures, b-roll) are served via x402-gated endpoints. Every time a creator imports or renders a licensed asset into their timeline, a 0.01 USDC micro-royalty is triggered. LicenseLock settles 'Proof of Use' instantly, allowing small indie studios to access high-end asset libraries without massive upfront buyout fees, while ensuring artists are paid for every second of screentime. Why Hedera: Traditional licensing is binary (owned or not). x402 enables 'metered' licensing, turning static assets into recurring revenue streams that scale with the user's project size. Market: TAM $18B — The global stock media and digital asset management market. | SAM $450M — The independent animation and VFX asset market, shifting toward per-project licensing models. | SOM $12M — Base-native creators and AI-generated video pipelines requiring instant, programmatic asset clearance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LicenseLock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-frame licensing engine for animators. Instead of complex legal paperwork, motion assets (rigs, textures, b-roll) are served via x402-gated endpoints. Every time a creator imports or renders a licensed asset into their timeline, a 0.01 USDC micro-royalty is triggered. LicenseLock settles 'Proof of Use' instantly, allowing small indie studios to access high-end asset libraries without massive upfront buyout fees, while ensuring artists are paid for every second of screentime. Discipline: Filmmaking & Animation (license management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is binary (owned or not). x402 enables 'metered' licensing, turning static assets into recurring revenue streams that scale with the user's project size. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LicenseLock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animcred-score-6-x402 Title: CREDIT · x402 Theme: Filmmaking & Animation (film-animation) · creator reputation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn your craft into a liquid asset. 'PROOF' isn't a badge; it's a metered verification. Animators pay 0.01 USDC to attest a frame or scene to their global score, while studios pay 0.01 USDC to query a creator's integrity-verified portfolio. By pricing every reputation update, we eliminate sybil-spam and create a high-signal leaderboard where every point of credit was bought with creative work and settled onchain. Why Hedera: Replacing free 'likes' with paid 'attestations' creates a high-stakes reputation economy. Moving the cost to the query/update level ensures that only serious creators and scouts participate, turning the score into a financial primitive for talent scouting. Market: TAM $8.5B — The global animation and VFX talent acquisition market shifting toward automated, algorithmic vetting. | SAM $420M — Professional animators and visual effects artists moving to freelance-first, gig-based onchain work. | SOM $12M — Early adopters in the web3 animation and 'Sakuga' enthusiast communities requiring verifiable credit for indie productions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CREDIT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn your craft into a liquid asset. 'PROOF' isn't a badge; it's a metered verification. Animators pay 0.01 USDC to attest a frame or scene to their global score, while studios pay 0.01 USDC to query a creator's integrity-verified portfolio. By pricing every reputation update, we eliminate sybil-spam and create a high-signal leaderboard where every point of credit was bought with creative work and settled onchain. Discipline: Filmmaking & Animation (creator reputation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Replacing free 'likes' with paid 'attestations' creates a high-stakes reputation economy. Moving the cost to the query/update level ensures that only serious creators and scouts participate, turning the score into a financial primitive for talent scouting. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CREDIT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-sceneswap-dex-7-x402 Title: FrameGate · x402 Theme: Filmmaking & Animation (film-animation) · asset exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity asset library where environment sets, lighting rigs, and character rigs are metered by the component. Instead of buying whole packs, creators pay $0.01 to sample, view, or pull a specific .USDZ or .FBX file directly into their viewport. Payment is the key that unlocks the secure binary stream from IPFS/Arweave. Why Hedera: Traditional asset stores suffer from high friction and 'bundled waste.' x402 allows for granular, per-asset micro-transactions, enabling hobbyists to build scenes for pennies and professional creators to monetize every individual light-rig they develop. Market: TAM $22B — The total addressable market for 3D digital assets, VFX software, and metaverse infrastructure. | SAM $450M — The segment of the CGI market focused on independent game devs, VR creators, and social media animators. | SOM $12M — The initial niche of USDC-native digital nomads and AI-driven animation agents requiring assets via API. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity asset library where environment sets, lighting rigs, and character rigs are metered by the component. Instead of buying whole packs, creators pay $0.01 to sample, view, or pull a specific .USDZ or .FBX file directly into their viewport. Payment is the key that unlocks the secure binary stream from IPFS/Arweave. Discipline: Filmmaking & Animation (asset exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional asset stores suffer from high friction and 'bundled waste.' x402 allows for granular, per-asset micro-transactions, enabling hobbyists to build scenes for pennies and professional creators to monetize every individual light-rig they develop. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animguild-dao-8-x402 Title: AnimGuild · x402 Theme: Filmmaking & Animation (film-animation) · community governance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A community governance layer for animation projects where every vote, asset review, and script approval is a micro-transaction. Instead of stagnant 'governance tokens,' stakeholders influence creative direction through real-time USDC micropayments. A 0.01 USDC gate ensures that every feedback loop or scene selection is backed by skin-in-the-game, instantly funding the production's treasury while preventing spam in high-stakes creative decisions. Why Hedera: Traditional DAOs suffer from voter apathy and whale manipulation. By leveraging x402, AnimGuild turns the 'decision' into a billable unit. This creates a sustainable loop where the community literally buys the frames they want to see, providing the animators with instant liquidity and clear, prioritized creative signals. Market: TAM $3.2B — The global animation and VFX outsourcing market, increasingly shifting toward decentralized/distributed workforces. | SAM $140M — Professional animation guilds and indie studios transitioning to community-led co-production models. | SOM $6.5M — Niche creative communities on Hedera using HTS transfer for friction-less creative voting and scene gating. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "AnimGuild" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A community governance layer for animation projects where every vote, asset review, and script approval is a micro-transaction. Instead of stagnant 'governance tokens,' stakeholders influence creative direction through real-time USDC micropayments. A 0.01 USDC gate ensures that every feedback loop or scene selection is backed by skin-in-the-game, instantly funding the production's treasury while preventing spam in high-stakes creative decisions. Discipline: Filmmaking & Animation (community governance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional DAOs suffer from voter apathy and whale manipulation. By leveraging x402, AnimGuild turns the 'decision' into a billable unit. This creates a sustainable loop where the community literally buys the frames they want to see, providing the animators with instant liquidity and clear, prioritized creative signals. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "AnimGuild" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-renderstake-9-x402 Title: RAYCAST · x402 Theme: Filmmaking & Animation (film-animation) · distributed rendering Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Distribute 3D frame rendering across a global node network with per-frame micro-settlement. Instead of heavy subscriptions or gas-intensive escrow, render nodes sign for 0.01 USDC per frame processed. High-fidelity animation becomes a pay-as-you-render utility, allowing creators to scale compute instantly without upfront overhead. Why Hedera: Shifting from 'escrow' to 'micro-metering' removes the friction of locking large capital. x402 enables a 'Pay-Per-Frame' architecture where the app front-end only triggers payments upon successful delivery of frame data, perfectly suiting the high-volume, low-latency needs of distributed rendering. Market: TAM $32B — The global 3D animation and rendering software market shifting to distributed architectures. | SAM $4.2B — The cloud rendering and VFX outsourcing sector accessible via decentralized compute. | SOM $85M — Independent animators and boutique studios using Blender/Unreal Engine on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RAYCAST" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Distribute 3D frame rendering across a global node network with per-frame micro-settlement. Instead of heavy subscriptions or gas-intensive escrow, render nodes sign for 0.01 USDC per frame processed. High-fidelity animation becomes a pay-as-you-render utility, allowing creators to scale compute instantly without upfront overhead. Discipline: Filmmaking & Animation (distributed rendering). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from 'escrow' to 'micro-metering' removes the friction of locking large capital. x402 enables a 'Pay-Per-Frame' architecture where the app front-end only triggers payments upon successful delivery of frame data, perfectly suiting the high-volume, low-latency needs of distributed rendering. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RAYCAST" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animproof-timestamp-10-x402 Title: AnimProof · x402 Theme: Filmmaking & Animation (film-animation) · work timestamping Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Secure your creative lineage with frame-by-frame provenance. AnimProof enables animators to sign and timestamp work-in-progress exports or final renders directly to Base. Each 0.01 USDC x402 call anchors a cryptographic hash of your media, generating a permanent, court-defensible record of creation. No subscriptions—just pay per proof to protect your intellectual property from AI scraping and plagiarism. Why Hedera: By turning timestamping into a high-frequency micropayment event, animators can 'save' their progress on-chain at every milestone without the friction of large gas fees or monthly commitments. x402 makes IP protection a granular utility. Market: TAM $2.1B — Mapping the global digital rights management (DRM) and animation production market. | SAM $140M — Targeted at independent animators, VFX houses, and digital artists requiring verifiable audit trails. | SOM $8M — Initial capture of freelance motion designers and character artists on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "AnimProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Secure your creative lineage with frame-by-frame provenance. AnimProof enables animators to sign and timestamp work-in-progress exports or final renders directly to Base. Each 0.01 USDC x402 call anchors a cryptographic hash of your media, generating a permanent, court-defensible record of creation. No subscriptions—just pay per proof to protect your intellectual property from AI scraping and plagiarism. Discipline: Filmmaking & Animation (work timestamping). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning timestamping into a high-frequency micropayment event, animators can 'save' their progress on-chain at every milestone without the friction of large gas fees or monthly commitments. x402 makes IP protection a granular utility. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "AnimProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-voicechain-sync-11-x402 Title: Phoneme · x402 Theme: Filmmaking & Animation (film-animation) · voice sync verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A frame-accurate verification layer for character animation. $0.01 USDC per frame-sync check to cryptographically verify that voice-actor biometric signatures match the animation's phoneme triggers. Prevents AI-cloned voice drift and ensures provenance for premium lip-sync workflows. Pay-per-verification eliminates heavy upfront studio licensing for indie animators. Why Hedera: By turning synchronization into a metered micro-transaction, production houses can bill 'verification-as-a-service' directly to their rendering pipeline, ensuring every millisecond of audio is authenticated on-chain. Market: TAM $8.2B — The global 2D/3D animation software and voice-over production industry. | SAM $450M — The independent animation and dubbing market adopting decentralized provenance. | SOM $12M — Web3 animation studios and AI-voice integration pipelines. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Phoneme" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A frame-accurate verification layer for character animation. $0.01 USDC per frame-sync check to cryptographically verify that voice-actor biometric signatures match the animation's phoneme triggers. Prevents AI-cloned voice drift and ensures provenance for premium lip-sync workflows. Pay-per-verification eliminates heavy upfront studio licensing for indie animators. Discipline: Filmmaking & Animation (voice sync verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning synchronization into a metered micro-transaction, production houses can bill 'verification-as-a-service' directly to their rendering pipeline, ensuring every millisecond of audio is authenticated on-chain. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Phoneme" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animtip-jar-12-x402 Title: FrameFuel · x402 Theme: Filmmaking & Animation (film-animation) · micro-donations Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Transform the passive tip jar into an active 'frame-by-frame' patronage engine. Using x402, fans don't just donate; they micro-meter the rendering of exclusive scenes. Every 0.01 USDC signed via the embedded wallet instantly unlocks the next 5 seconds of a work-in-progress storyboard or high-fidelity render. This creates a real-time feedback loop where animators are paid per-view, per-frame, ensuring that the cost of compute and craft is covered by the audience in granular increments as they watch. Why Hedera: Transitioning from static tips to per-second/per-frame 'metered viewing' creates a sustainable flow for creators while ensuring viewers only pay for what they consume. x402 eliminates the friction of traditional checkout for micro-amounts. Market: TAM $30B — The global animation and VFX industry shifting toward decentralized, direct-to-consumer monetization models. | SAM $450M — The addressable segment of independent animators and digital motion artists utilizing web3 rails for distribution. | SOM $12M — Target capture of high-frequency micro-transactions within the Base animator ecosystem in year one. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameFuel" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Transform the passive tip jar into an active 'frame-by-frame' patronage engine. Using x402, fans don't just donate; they micro-meter the rendering of exclusive scenes. Every 0.01 USDC signed via the embedded wallet instantly unlocks the next 5 seconds of a work-in-progress storyboard or high-fidelity render. This creates a real-time feedback loop where animators are paid per-view, per-frame, ensuring that the cost of compute and craft is covered by the audience in granular increments as they watch. Discipline: Filmmaking & Animation (micro-donations). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from static tips to per-second/per-frame 'metered viewing' creates a sustainable flow for creators while ensuring viewers only pay for what they consume. x402 eliminates the friction of traditional checkout for micro-amounts. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameFuel" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-charanim-nft-13-x402 Title: Loomis · x402 Theme: Filmmaking & Animation (film-animation) · character IP NFT Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized license server where animation studios and indie creators stream Character IP assets to engines (Unity/Unreal) and social apps. Instead of clunky licensing contracts, developers pay 0.01 USDC per frame-render or 3D model call. Every time your character appears in a game or a video, the owner is paid instantly via the protocol. IP rights governed by high-frequency usage telemetry. Why Hedera: Transitioning IP from 'static ownership' to 'metered usage' solves the cold-start problem for creators. x402 allows developers to prototype with AAA-quality assets for cents before committing to full licensing, while providing automated, transparent royalty distribution to IP holders on Hedera. Market: TAM $380B — Global media licensing and character IP industry. | SAM $4.2B — The market for licensed merchandise and digital assets within the creator economy and indie gaming. | SOM $150M — Early-stage indie game developers and VTubers on Hedera seeking plug-and-play character IP. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Loomis" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized license server where animation studios and indie creators stream Character IP assets to engines (Unity/Unreal) and social apps. Instead of clunky licensing contracts, developers pay 0.01 USDC per frame-render or 3D model call. Every time your character appears in a game or a video, the owner is paid instantly via the protocol. IP rights governed by high-frequency usage telemetry. Discipline: Filmmaking & Animation (character IP NFT). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning IP from 'static ownership' to 'metered usage' solves the cold-start problem for creators. x402 allows developers to prototype with AAA-quality assets for cents before committing to full licensing, while providing automated, transparent royalty distribution to IP holders on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Loomis" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animlesson-chain-14-x402 Title: FrameProof · x402 Theme: Filmmaking & Animation (film-animation) · educational credentialing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A peer-reviewed animation syllabus where every feedback loop and asset critique is a paid micro-consultation. Students pay 0.01 USDC to submit a shot for frame-by-frame analysis by top-tier industry veterans or AI-critique agents. Upon approval, a final 0.01 USDC payment triggers the minting of a skill-specific 'Proof of Proficiency' credential stored on-chain. Stop paying for bloated courses; pay per breakthrough. Why Hedera: Traditional credentials are static. By turning the review process into a metered transaction, we monetize the expert's time at a granular level and ensure the student only pays for the specific feedback they need to level up. Market: TAM $14B — Global online vocational and creative education market. | SAM $420M — Professional development fees within the global animation and VFX workforce. | SOM $12M — Specialized technical certification for junior animators entering the 10 largest global studios. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A peer-reviewed animation syllabus where every feedback loop and asset critique is a paid micro-consultation. Students pay 0.01 USDC to submit a shot for frame-by-frame analysis by top-tier industry veterans or AI-critique agents. Upon approval, a final 0.01 USDC payment triggers the minting of a skill-specific 'Proof of Proficiency' credential stored on-chain. Stop paying for bloated courses; pay per breakthrough. Discipline: Filmmaking & Animation (educational credentialing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional credentials are static. By turning the review process into a metered transaction, we monetize the expert's time at a granular level and ensure the student only pays for the specific feedback they need to level up. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animcontest-dao-15-x402 Title: FrameRate · x402 Theme: Filmmaking & Animation (film-animation) · competition governance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An adversarial animation judge where every vote, submission, and adjudication is a micro-transaction. Filmmakers pay 0.01 USDC to submit frames for verification, and voters pay 0.01 USDC to weigh in, creating a sybil-resistant stake in the outcome. Winners are determined by the volume of processed micro-transactions, with prize pools streaming instantly upon every interaction. Why Hedera: By turning governance into a high-frequency micropayment stream, we eliminate bot-spam and ensure only skins-in-the-game participants influence the leaderboard. Competition integrity is enforced by the cost of participation. Market: TAM $900M — Global animation contest and film festival entry fee market, shifting to transparent, per-action accounting. | SAM $45M — Estimated for niche web3-native creative communities and decentralized film festivals. | SOM $1.2M — Based on 120k interactions across the first 10 flagship animation cycles. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameRate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An adversarial animation judge where every vote, submission, and adjudication is a micro-transaction. Filmmakers pay 0.01 USDC to submit frames for verification, and voters pay 0.01 USDC to weigh in, creating a sybil-resistant stake in the outcome. Winners are determined by the volume of processed micro-transactions, with prize pools streaming instantly upon every interaction. Discipline: Filmmaking & Animation (competition governance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning governance into a high-frequency micropayment stream, we eliminate bot-spam and ensure only skins-in-the-game participants influence the leaderboard. Competition integrity is enforced by the cost of participation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameRate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animscript-oracles-16-x402 Title: ScriptSeal · x402 Theme: Filmmaking & Animation (film-animation) · script verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Institutional-grade script verification for the decentralized film era. Pay 0.01 USDC to cryptographically seal or verify a scene's hash, ensuring IP provenance and revision control before production begins. Each signature acts as a micro-notary for the creative pipeline. Why Hedera: By moving from a 'service' to a 'micro-metered protocol,' writers and studios can verify script integrity on a per-scene or per-revision basis, turning mundane version control into a secure, revenue-generating audit trail. Market: TAM $3.2B — Global animation production and IP protection services. | SAM $240M — Script analysis and digital rights management (DRM) software market. | SOM $12M — Web3-native production houses and decentralized animation studios utilizing Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScriptSeal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Institutional-grade script verification for the decentralized film era. Pay 0.01 USDC to cryptographically seal or verify a scene's hash, ensuring IP provenance and revision control before production begins. Each signature acts as a micro-notary for the creative pipeline. Discipline: Filmmaking & Animation (script verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a 'service' to a 'micro-metered protocol,' writers and studios can verify script integrity on a per-scene or per-revision basis, turning mundane version control into a secure, revenue-generating audit trail. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScriptSeal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animroyalties-17-x402 Title: FrameRate · x402 Theme: Filmmaking & Animation (film-animation) · royalty distribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-frame revenue distribution layer. Every time an animation asset is rendered, displayed, or API-queried, 0.01 USDC is instantly streamed to the contributor's wallet. Forget monthly accounting; earn as the playhead moves. Why Hedera: By shifting from aggregate back-end settlements to x402-metered consumption, animators receive instant micro-residuals. This turns animation assets into productive, self-clearing capital. Market: TAM $390B — The global animation and VFX industry shifting to automated licensing. | SAM $1.2B — Micro-licensing for independent studios and motion designers. | SOM $15M — Base-native animation collectives and CC0 remixers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameRate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-frame revenue distribution layer. Every time an animation asset is rendered, displayed, or API-queried, 0.01 USDC is instantly streamed to the contributor's wallet. Forget monthly accounting; earn as the playhead moves. Discipline: Filmmaking & Animation (royalty distribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from aggregate back-end settlements to x402-metered consumption, animators receive instant micro-residuals. This turns animation assets into productive, self-clearing capital. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameRate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-scenemood-chain-18-x402 Title: GradeLock · x402 Theme: Filmmaking & Animation (film-animation) · color grading proof Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Color grading is a series of expensive micro-decisions. SceneMood turns color look-up tables (LUTs) and grade metadata into paid primitives. Instead of sending watermarked files, colorists host secure previews where directors pay 0.01 USDC to toggle a specific look or 'commit' a grade to the master timeline. Every color decision is a signed transaction, creating a cryptographically verifiable 'Director’s Cut' audit trail where the creator is paid for every iteration, not just the final export. Why Hedera: Shifts post-production from a flat-fee service to a metered creative-audit model. It eliminates billing disputes by making every 'look' a tiny, paid event recorded on-chain. Market: TAM $14.2B — The global digital cinema and video editing software market transitioning to decentralized asset management. | SAM $850M — The independent post-production and freelance colorist market moving toward remote-first workflows. | SOM $12M — Early adopter boutique studios and indie filmmakers using Base for secure, high-speed creative sign-offs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GradeLock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Color grading is a series of expensive micro-decisions. SceneMood turns color look-up tables (LUTs) and grade metadata into paid primitives. Instead of sending watermarked files, colorists host secure previews where directors pay 0.01 USDC to toggle a specific look or 'commit' a grade to the master timeline. Every color decision is a signed transaction, creating a cryptographically verifiable 'Director’s Cut' audit trail where the creator is paid for every iteration, not just the final export. Discipline: Filmmaking & Animation (color grading proof). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts post-production from a flat-fee service to a metered creative-audit model. It eliminates billing disputes by making every 'look' a tiny, paid event recorded on-chain. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GradeLock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animbadge-awards-19-x402 Title: CREDIT · x402 Theme: Filmmaking & Animation (film-animation) · industry recognition Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized talent-vetting protocol for the animation industry. Production houses pay 0.01 USDC to verify a freelancer's specific technical skill-badge (e.g., 'Rigging Mastery' or 'Keyframe Efficiency') via HTS transfer. Instead of static PDFs, credentials are live-gated assets. Animators pay a micro-fee to submit work for peer-audit, and recruiters pay to unlock 'Proof of Proficiency' metadata, creating a high-signal, low-friction labor market. Why Hedera: Industry recognition is currently plagued by 'resume inflation' and siloed awards. By turning verification into a micropayment event, we create an economic filter for quality. Small fees on both sides (submission/verification) ensure that only serious talent and serious recruiters interact, with each interaction living on-chain as a verifiable tx hash. Market: TAM $2.4B — The total addressable market for global professional certification and talent acquisition within the $400B+ Media & Entertainment sector. | SAM $120M — The global production house recruitment and digital credentialing budget for independent creative studios. | SOM $8M — Initial capture of freelance marketplaces (Upwork/Fiverr) for animators transitioning to decentralized 'Verified-by-Work' models on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CREDIT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized talent-vetting protocol for the animation industry. Production houses pay 0.01 USDC to verify a freelancer's specific technical skill-badge (e.g., 'Rigging Mastery' or 'Keyframe Efficiency') via HTS transfer. Instead of static PDFs, credentials are live-gated assets. Animators pay a micro-fee to submit work for peer-audit, and recruiters pay to unlock 'Proof of Proficiency' metadata, creating a high-signal, low-friction labor market. Discipline: Filmmaking & Animation (industry recognition). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Industry recognition is currently plagued by 'resume inflation' and siloed awards. By turning verification into a micropayment event, we create an economic filter for quality. Small fees on both sides (submission/verification) ensure that only serious talent and serious recruiters interact, with each interaction living on-chain as a verifiable tx hash. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CREDIT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animframe-trade-20-x402 Title: Cell Meter · x402 Theme: Filmmaking & Animation (film-animation) · frame licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A programmatic frame-buffer for high-fidelity animation assets. Creators mount their project folders to the x402 gateway; animators and editors pay 0.01 USDC to instantly pull a high-res, royalty-cleared frame or keyframe asset into their local workspace. Payment is the literal 'Get Frame' command. Transactions are signed via the embedded wallet, allowing for high-speed, frame-by-frame compositing without manual invoicing. Why Hedera: Traditional licensing is too slow for modern workflows. By making the payment the API call, we enable 'Dynamic Rotoscoping' where an AI or editor pays per frame fetched, turning animation libraries into liquid, metered infrastructure. Market: TAM $32B — The global digital animation and VFX content market. | SAM $1.2B — Professional animation studios and motion designers migrating to cloud-based asset management. | SOM $18M — Independent animators and AI-generative video creators using pay-as-you-go asset libraries. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Cell Meter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A programmatic frame-buffer for high-fidelity animation assets. Creators mount their project folders to the x402 gateway; animators and editors pay 0.01 USDC to instantly pull a high-res, royalty-cleared frame or keyframe asset into their local workspace. Payment is the literal 'Get Frame' command. Transactions are signed via the embedded wallet, allowing for high-speed, frame-by-frame compositing without manual invoicing. Discipline: Filmmaking & Animation (frame licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is too slow for modern workflows. By making the payment the API call, we enable 'Dynamic Rotoscoping' where an AI or editor pays per frame fetched, turning animation libraries into liquid, metered infrastructure. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Cell Meter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animpitch-fund-21-x402 Title: FrameRate · x402 Theme: Filmmaking & Animation (film-animation) · project pitching Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to 'Flash-Greenlight' animation pitches. Instead of long-tail fundraising, creators monetize the pitch deck itself through micro-stakes feedback and voting. Investors pay a micropayment to reveal high-fidelity storyboards, while creators pay to push their pitch to the front of a global curator queue. Every 'Like' is a settlement, every 'Peer Review' is a transaction. Validates market demand before a single frame is rendered. Why Hedera: Traditional crowdfunding is high-friction ($10+ minimums). AnimPitch converts attention into instant equity micro-budgeting. By making the pitch the product, creators earn a living wage through the curation phase, and investors filter signal from noise via skin-in-the-game micropayments. Market: TAM $390B — The global animation, VFX, and video game pre-production industry. | SAM $850M — The independent animation and pilot production market moving toward decentralized distribution. | SOM $12M — Early-stage 'proof of concept' animatics and storyboard artists on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameRate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to 'Flash-Greenlight' animation pitches. Instead of long-tail fundraising, creators monetize the pitch deck itself through micro-stakes feedback and voting. Investors pay a micropayment to reveal high-fidelity storyboards, while creators pay to push their pitch to the front of a global curator queue. Every 'Like' is a settlement, every 'Peer Review' is a transaction. Validates market demand before a single frame is rendered. Discipline: Filmmaking & Animation (project pitching). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional crowdfunding is high-friction ($10+ minimums). AnimPitch converts attention into instant equity micro-budgeting. By making the pitch the product, creators earn a living wage through the curation phase, and investors filter signal from noise via skin-in-the-game micropayments. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameRate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-licenchain-tracker-22-x402 Title: FrameTrace · x402 Theme: Filmmaking & Animation (film-animation) · license audit trail Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Audit is the new escrow. Every time a creative asset is used, rendered, or sub-licensed in an animation pipeline, the x402 protocol triggers a 0.01 USDC event signature. This creates a high-fidelity, paid audit trail where the history of the license is the product of its usage. No more bulk licensing fees upfront—pay for the specific frame or sequence you deploy, with a verifiable Hedera transaction id protecting the legal chain of custody for every micro-transaction. Why Hedera: Traditional licensing relies on lump sums and trust. x402 turns license tracking into a metered utility. By charging per 'audit check' or 'usage ping,' we create a granular ledger that rewards creators for high-frequency small-scale use and provides animators with an automated, pay-as-you-go compliance engine. Market: TAM $18B — Global digital rights management (DRM) and media asset licensing market. | SAM $420M — The independent animation and VFX market adopting micro-licensing models. | SOM $12M — Series-A animation studios and freelance motion designers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Audit is the new escrow. Every time a creative asset is used, rendered, or sub-licensed in an animation pipeline, the x402 protocol triggers a 0.01 USDC event signature. This creates a high-fidelity, paid audit trail where the history of the license is the product of its usage. No more bulk licensing fees upfront—pay for the specific frame or sequence you deploy, with a verifiable Hedera transaction id protecting the legal chain of custody for every micro-transaction. Discipline: Filmmaking & Animation (license audit trail). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing relies on lump sums and trust. x402 turns license tracking into a metered utility. By charging per 'audit check' or 'usage ping,' we create a granular ledger that rewards creators for high-frequency small-scale use and provides animators with an automated, pay-as-you-go compliance engine. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animmetamanager-23-x402 Title: FrameLock · x402 Theme: Filmmaking & Animation (film-animation) · metadata control Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — write-once, audit-always. Every frame of your production contains a digital 'paper trail.' Pay per metadata update or rights-transfer signature. Animators use this to lock in provenance timestamps, while studio heads pay per-call to audit the chain of custody. No batch fees—just instant, granular validation of creative ownership as it happens in the viewport. Why Hedera: By moving metadata updates to an x402 model, the cost of provenance is tied directly to production activity. It prevents bulk database tampering and creates a verifiable financial trail for every change in creative rights. Market: TAM $12.4B — The global digital asset management (DAM) and animation production market. | SAM $1.6B — Independent animation studios and freelance digital artists requiring immutable rights-management. | SOM $85M — Pre-production metadata logging for decentralized animation collectives and Web3 IP houses. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameLock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — write-once, audit-always. Every frame of your production contains a digital 'paper trail.' Pay per metadata update or rights-transfer signature. Animators use this to lock in provenance timestamps, while studio heads pay per-call to audit the chain of custody. No batch fees—just instant, granular validation of creative ownership as it happens in the viewport. Discipline: Filmmaking & Animation (metadata control). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving metadata updates to an x402 model, the cost of provenance is tied directly to production activity. It prevents bulk database tampering and creates a verifiable financial trail for every change in creative rights. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameLock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animcollab-chain-24-x402 Title: Framesync · x402 Theme: Filmmaking & Animation (film-animation) · collaborative workflows Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized render farm and keyframe marketplace where payment is metered by the frame. Collaborators don't just 'assign tasks'; they stream 0.01 USDC payloads to unlock layers, high-res exported assets, or unique character rigs. An animator pulls a rig, a compositor pulls a background—each micro-transaction triggers the release of the raw source file via the facilitator, ensuring frictionless payment for every contribution in the pipeline. Why Hedera: Modern animation is bottlenecked by large file transfers and vague 'milestone' payments. By shifting to a per-asset or per-frame micropayment model via x402, we turn the production pipeline into a real-time economy. Animators are paid instantly for every successful layer commit, and stakeholders pay exactly for the time/frames rendered. Market: TAM $30B — The global 2D/3D animation production and software pipeline industry. | SAM $850M — The independent animation and boutique VFX studio market utilizing outsourced talent. | SOM $12M — Web3-native animators and decentralized creative studios using Base for collaborative production. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Framesync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized render farm and keyframe marketplace where payment is metered by the frame. Collaborators don't just 'assign tasks'; they stream 0.01 USDC payloads to unlock layers, high-res exported assets, or unique character rigs. An animator pulls a rig, a compositor pulls a background—each micro-transaction triggers the release of the raw source file via the facilitator, ensuring frictionless payment for every contribution in the pipeline. Discipline: Filmmaking & Animation (collaborative workflows). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Modern animation is bottlenecked by large file transfers and vague 'milestone' payments. By shifting to a per-asset or per-frame micropayment model via x402, we turn the production pipeline into a real-time economy. Animators are paid instantly for every successful layer commit, and stakeholders pay exactly for the time/frames rendered. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Framesync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-frameforge-archive-0-x402 Title: FrameForge · x402 Theme: Filmmaking & Animation (film-animation) · storyboard management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity storyboarding hub where every frame is a cryptographically secured asset. Use x402 to pay-per-frame for AI-assisted cleanup, pay-to-unlock high-res exports, and meter team access to production bibles. Instant settlement allows animators to 'stream' their IP to studios with every scroll. Why Hedera: Moving from 'storage' to 'metered IP consumption' transforms storyboards from static files into live, revenue-generating streams for artists and pre-viz houses. Market: TAM $22B — The global animation and VFX market moving toward real-time collaboration. | SAM $850M — The pre-production and visualization segment of the animation industry. | SOM $12M — Web3-native animation studios and independent creators using Base for IP management. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameForge" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity storyboarding hub where every frame is a cryptographically secured asset. Use x402 to pay-per-frame for AI-assisted cleanup, pay-to-unlock high-res exports, and meter team access to production bibles. Instant settlement allows animators to 'stream' their IP to studios with every scroll. Discipline: Filmmaking & Animation (storyboard management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'storage' to 'metered IP consumption' transforms storyboards from static files into live, revenue-generating streams for artists and pre-viz houses. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameForge" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-texturevault-1-x402 Title: Grain · x402 Theme: Filmmaking & Animation (film-animation) · material asset library Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Surface-as-a-Service for 3D pipelines. Unlock high-fidelity, production-ready material shaders (PBR) on-demand. No subscriptions—pro animators pay 0.01 USDC to instantly pull a unique texture hash into their workspace, with provenance baked into every transaction. Why Hedera: Shifts from a passive storage 'vault' to a high-velocity utility. x402 eliminates the friction of credit systems, allowing render engines or artist plugins to micro-pay for assets only when they hit 'import'. Market: TAM $18B — Global 3D mapping and texture rendering software market. | SAM $450M — The independent VFX and 3D animation tool market. | SOM $12M — Direct-to-engine asset distribution for Base-native digital artists. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Grain" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Surface-as-a-Service for 3D pipelines. Unlock high-fidelity, production-ready material shaders (PBR) on-demand. No subscriptions—pro animators pay 0.01 USDC to instantly pull a unique texture hash into their workspace, with provenance baked into every transaction. Discipline: Filmmaking & Animation (material asset library). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from a passive storage 'vault' to a high-velocity utility. x402 eliminates the friction of credit systems, allowing render engines or artist plugins to micro-pay for assets only when they hit 'import'. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Grain" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animscene-sync-2-x402 Title: GhostFrame · x402 Theme: Filmmaking & Animation (film-animation) · scene version control Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-commit versioning for 3D scenes. $0.01 USDC anchors a production-ready manifest to IPFS with a signed Hedera transaction id, creating an immutable audit trail of creative decisions. Instead of messy local files, animators pay a micro-fee to 'Lock & Sync' a state, allowing collaborators to instantly pull specific iterations via the ledger. No subscription, just pay for the frames you finalize. Why Hedera: By turning the 'Save' action into a paid HTS transfer transaction, version control becomes a verifiable asset. It prevents data bloat by ensuring only meaningful iterations are recorded, while providing a permanent, non-repudiable history of a film's evolution for production audits. Market: TAM $4.8B — The global animation and VFX software market shifting toward cloud-native, granular collaboration tools. | SAM $220M — Professional freelance animators and boutique VFX houses transitioning to decentralized asset pipelines. | SOM $12M — Web3-native animation studios and decentralized autonomous film productions (DAOs) using IPFS/Base stacks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GhostFrame" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-commit versioning for 3D scenes. $0.01 USDC anchors a production-ready manifest to IPFS with a signed Hedera transaction id, creating an immutable audit trail of creative decisions. Instead of messy local files, animators pay a micro-fee to 'Lock & Sync' a state, allowing collaborators to instantly pull specific iterations via the ledger. No subscription, just pay for the frames you finalize. Discipline: Filmmaking & Animation (scene version control). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the 'Save' action into a paid HTS transfer transaction, version control becomes a verifiable asset. It prevents data bloat by ensuring only meaningful iterations are recorded, while providing a permanent, non-repudiable history of a film's evolution for production audits. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GhostFrame" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-moodboardchain-3-x402 Title: ChromaGate · x402 Theme: Filmmaking & Animation (film-animation) · color study curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-lookup library of provable color palettes extracted from world-class cinematography. Users pay 0.01 USDC to 'Capture' a frame's LUT (Look Up Table) data and color metadata via IPFS. Animators and colorists meter their inspiration, paying only for the specific palettes they export into their workspace. Facilitators settle high-fidelity color telemetry on Hedera, ensuring the original curator gets a micro-royalty for every style 'forked' by another artist. Why Hedera: Shifts the model from a static 'board' to an active 'extraction' tool. By charging per palette unlock, it monetizes the curator's eye and treats cinematic data as a metered asset for professional pipelines. Market: TAM $45B — The global animation and VFX software market, increasingly moving toward granular, cloud-based asset libraries. | SAM $1.2B — Indie filmmakers, motion designers, and freelance colorists using digital grading suites (Resolve/Adobe). | SOM $15M — Early adopters in the Base and Zora ecosystems seeking on-chain provenance for visual aesthetics. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ChromaGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-lookup library of provable color palettes extracted from world-class cinematography. Users pay 0.01 USDC to 'Capture' a frame's LUT (Look Up Table) data and color metadata via IPFS. Animators and colorists meter their inspiration, paying only for the specific palettes they export into their workspace. Facilitators settle high-fidelity color telemetry on Hedera, ensuring the original curator gets a micro-royalty for every style 'forked' by another artist. Discipline: Filmmaking & Animation (color study curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from a static 'board' to an active 'extraction' tool. By charging per palette unlock, it monetizes the curator's eye and treats cinematic data as a metered asset for professional pipelines. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ChromaGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-rigpin-sync-4-x402 Title: Skeleton · x402 Theme: Filmmaking & Animation (film-animation) · character rig sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay $0.01 per rig-fetch to instantly pull production-ready animation skeletons and metadata from the IPFS global archive. No subscriptions, just micro-settlements per skeleton. Riggers earn 80% on every pull, creating a high-velocity, low-friction market for verified character tech. Why Hedera: Character rigging is a high-cost bottleneck. By shifting from 'repository access' to 'pay-per-pull' via x402, riggers get instant liquidity for their technical overhead, and animators get high-end rigs for pennies without licensing friction. Market: TAM $42B — The total addressable market for the global digital content creation (DCC) industry. | SAM $850M — The global 3D animation software and asset marketplace. | SOM $12M — Remote-first indie animation studios and freelance riggers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Skeleton" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay $0.01 per rig-fetch to instantly pull production-ready animation skeletons and metadata from the IPFS global archive. No subscriptions, just micro-settlements per skeleton. Riggers earn 80% on every pull, creating a high-velocity, low-friction market for verified character tech. Discipline: Filmmaking & Animation (character rig sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Character rigging is a high-cost bottleneck. By shifting from 'repository access' to 'pay-per-pull' via x402, riggers get instant liquidity for their technical overhead, and animators get high-end rigs for pennies without licensing friction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Skeleton" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-loop-provenance-5-x402 Title: KINETIC · x402 Theme: Filmmaking & Animation (film-animation) · animation loop libraries Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity motion library where every 'Import to Timeline' action triggers a 0.01 USDC micro-license. Eliminate bulk subscription waste; animators pay only for the specific walk-cycles, fluid sims, and UI loops they actually drop into their projects. The x402 settlement serves as the on-chain usage receipt, providing a transparent provenance trail for commercial clearance. Why Hedera: By shifting from a 'library access' model to a 'per-pull' model, high-quality animators get paid for every single instance their work is utilized, while indie creators avoid $50/mo subscriptions for a single loop. Market: TAM $2.4B — The global 2D/3D animation software and digital asset market. | SAM $180M — The specialized motion graphics and asset store segment within the creator economy. | SOM $12M — Web3-native animators and game devs utilizing Base for asset management. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity motion library where every 'Import to Timeline' action triggers a 0.01 USDC micro-license. Eliminate bulk subscription waste; animators pay only for the specific walk-cycles, fluid sims, and UI loops they actually drop into their projects. The x402 settlement serves as the on-chain usage receipt, providing a transparent provenance trail for commercial clearance. Discipline: Filmmaking & Animation (animation loop libraries). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a 'library access' model to a 'per-pull' model, high-quality animators get paid for every single instance their work is utilized, while indie creators avoid $50/mo subscriptions for a single loop. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-scenemetastore-6-x402 Title: SceneStamp · x402 Theme: Filmmaking & Animation (film-animation) · metadata tagging Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-write metadata anchoring for collaborative pipelines. Instead of a monolithic database, animators pay $0.01 USDC to permanently stamp Frame-Level JSON (composition, lighting, focal length) onto the Base ledger. SceneMeta enables a 'pay-per-query' model where VFX vendors or AI renders pull exact scene state via HTS transfer authorized reads, ensuring the metadata stays with the asset across every handoff. Why Hedera: By making every metadata write a micro-transaction, technical directors can audit the exact flow of scene changes while ensuring the data is decentralized. It transforms metadata from a sidecar file into a verifiable, paid event. Market: TAM $2.4B — The global animation and VFX software ecosystem moving toward decentralized asset management and AI-agent automation. | SAM $140M — The shared work-for-hire market between VFX houses and independent 3D contractors using micro-pipeline tools. | SOM $1.2M — Specialized technical directors and pipeline engineers on Hedera tagging shots for high-end web3 animation series. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SceneStamp" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-write metadata anchoring for collaborative pipelines. Instead of a monolithic database, animators pay $0.01 USDC to permanently stamp Frame-Level JSON (composition, lighting, focal length) onto the Base ledger. SceneMeta enables a 'pay-per-query' model where VFX vendors or AI renders pull exact scene state via HTS transfer authorized reads, ensuring the metadata stays with the asset across every handoff. Discipline: Filmmaking & Animation (metadata tagging). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making every metadata write a micro-transaction, technical directors can audit the exact flow of scene changes while ensuring the data is decentralized. It transforms metadata from a sidecar file into a verifiable, paid event. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SceneStamp" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-storyboardchain-7-x402 Title: FrameFlow · x402 Theme: Filmmaking & Animation (film-animation) · distributed storyboarding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A 'pay-per-frame' engine for collaborative world-building. Artists commit storyboard panels to the global canvas; directors pay 0.01 USDC to unlock full-resolution depth maps, metadata, or version-histories. Every review, 'like', or branch-point is a micro-settlement ensuring contributors are paid for every frame they influence. Why Hedera: Transitioning from a static 'chain' to a 'flow' of micropayments validates creative labor in real-time. By metering the access to high-fidelity assets and script revisions, the protocol turns a workspace into a live secondary market for narrative pre-production. Market: TAM $14B — Global cloud-based film production and storyboard software market. | SAM $450M — The independent animation and pre-visualization sector. | SOM $12M — Decentralized writers' rooms and indie animation studios using Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A 'pay-per-frame' engine for collaborative world-building. Artists commit storyboard panels to the global canvas; directors pay 0.01 USDC to unlock full-resolution depth maps, metadata, or version-histories. Every review, 'like', or branch-point is a micro-settlement ensuring contributors are paid for every frame they influence. Discipline: Filmmaking & Animation (distributed storyboarding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from a static 'chain' to a 'flow' of micropayments validates creative labor in real-time. By metering the access to high-fidelity assets and script revisions, the protocol turns a workspace into a live secondary market for narrative pre-production. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animprops-ledger-8-x402 Title: PropFlow · x402 Theme: Filmmaking & Animation (film-animation) · prop asset management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Prop-designers sell usage-rights to film studios and indies via 0.01 USDC 'Prop-Unlocks.' Every time a 3D asset is pulled into a scene, a micropayment settles on-chain. This creates a real-time royalty stream for digital prop houses and ensures clear chain-of-title for virtual production. Why Hedera: By shifting from static IPFS pinning to a pay-per-pull x402 model, we turn prop assets into metered APIs. Studios no longer need lump-sum licensing; they pay only for the props they actually render, while creators receive instant liquidity. Market: TAM $42B — Global VFX and animation production industry moving towards real-time engines. | SAM $920M — Virtual production and 3D asset marketplaces for film/VR. | SOM $14M — Independent animators and virtual streamers using modular assets on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PropFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Prop-designers sell usage-rights to film studios and indies via 0.01 USDC 'Prop-Unlocks.' Every time a 3D asset is pulled into a scene, a micropayment settles on-chain. This creates a real-time royalty stream for digital prop houses and ensures clear chain-of-title for virtual production. Discipline: Filmmaking & Animation (prop asset management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from static IPFS pinning to a pay-per-pull x402 model, we turn prop assets into metered APIs. Studios no longer need lump-sum licensing; they pay only for the props they actually render, while creators receive instant liquidity. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PropFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-palettechain-9-x402 Title: Chroma · x402 Theme: Filmmaking & Animation (film-animation) · color palette sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Every creative decision is an asset. Chroma unlocks color-grading LUTs and hexadecimal palettes via microminiaturized payments. No subscriptions; pay 0.01 USDC to pull a verified hex-string or .cube file from IPFS into your project. Creators earn per-pull, and AI-driven color scripts pay to sample human-curated moods. Payment is the key that decrypts the creative intent. Why Hedera: By turning a static reference into a metered asset, we solve the 'professional inspiration' gap. Instead of free Pinterest boards, this is a high-fidelity library where professional colorists are compensated every time a filmmaker or generative AI agent 'looks' at their work for inspiration or implementation. Market: TAM $4.2B — The global creative economy for filmmaking and digital design tools, projected to lean heavily into pay-per-use metadata by 2030. | SAM $850M — The digital creative asset market, specifically focusing on the shift from stock photography to metadata-driven design assets (LUTs, palettes, presets). | SOM $12M — Early-stage focus on independent filmmakers and AI-animation studios utilizing Base for low-cost asset synchronization. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chroma" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Every creative decision is an asset. Chroma unlocks color-grading LUTs and hexadecimal palettes via microminiaturized payments. No subscriptions; pay 0.01 USDC to pull a verified hex-string or .cube file from IPFS into your project. Creators earn per-pull, and AI-driven color scripts pay to sample human-curated moods. Payment is the key that decrypts the creative intent. Discipline: Filmmaking & Animation (color palette sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning a static reference into a metered asset, we solve the 'professional inspiration' gap. Instead of free Pinterest boards, this is a high-fidelity library where professional colorists are compensated every time a filmmaker or generative AI agent 'looks' at their work for inspiration or implementation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chroma" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animdata-store-10-x402 Title: KeyStream · x402 Theme: Filmmaking & Animation (film-animation) · animation data archival Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless infrastructure layer where animators and studios monetize raw motion data. Instead of selling a fixed asset, you license the 'ghost in the machine.' Pay 0.05 USDC to stream high-fidelity keyframe buffers (JSON/FBX) directly into your viewport via x402-gated IPFS pins. Every 'Import to Timeline' is a micro-settlement. Why Hedera: Current animation marketplaces are bloated with high-friction $50 packs. By atomizing motion data into pay-per-use keyframe sequences, creators can monetize individual walk cycles or physics-based gestures for the price of a penny, perfect for generative AI agents needing training data or indie devs on a budget. Market: TAM $400B — The global animation, VFX, and video game industry moving toward modular, real-time asset streaming. | SAM $1.4B — The technical animation and motion capture software market expanding into decentralized asset hosting. | SOM $85M — Independent technical animators and indie game studios utilizing Base for low-cost asset sourcing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KeyStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless infrastructure layer where animators and studios monetize raw motion data. Instead of selling a fixed asset, you license the 'ghost in the machine.' Pay 0.05 USDC to stream high-fidelity keyframe buffers (JSON/FBX) directly into your viewport via x402-gated IPFS pins. Every 'Import to Timeline' is a micro-settlement. Discipline: Filmmaking & Animation (animation data archival). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current animation marketplaces are bloated with high-friction $50 packs. By atomizing motion data into pay-per-use keyframe sequences, creators can monetize individual walk cycles or physics-based gestures for the price of a penny, perfect for generative AI agents needing training data or indie devs on a budget. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KeyStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-motionmap-vault-11-x402 Title: Kinetic · x402 Theme: Filmmaking & Animation (film-animation) · motion capture storage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Stream motion capture sequences directly to your game engine or rig via verifiable x402 calls. Instead of bulky downloads, pay-per-pull for specific skeletal data frames. MoCap creators earn USDC instantly every time an animator 'ghosts' their data for a scene. Built on Hedera for sub-cent latency and immutable IPFS storage anchors. Why Hedera: By shifting from 'storage' to 'streamed access', the data becomes a liquid commodity. High-fidelity motion data is expensive to produce; x402 enables a Pay-Per-Frame (PPF) model that makes professional kinetics affordable for indie devs while providing a recurring revenue stream for actors. Market: TAM $28B — The global 3D animation and motion capture market across film, gaming, and digital twins. | SAM $1.2B — Professional animation studios, indie game developers, and VTubers requiring high-fidelity locomotion data. | SOM $45M — The niche of AI-driven procedurally generated character movement and skeletal data marketplaces. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Stream motion capture sequences directly to your game engine or rig via verifiable x402 calls. Instead of bulky downloads, pay-per-pull for specific skeletal data frames. MoCap creators earn USDC instantly every time an animator 'ghosts' their data for a scene. Built on Hedera for sub-cent latency and immutable IPFS storage anchors. Discipline: Filmmaking & Animation (motion capture storage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'storage' to 'streamed access', the data becomes a liquid commodity. High-fidelity motion data is expensive to produce; x402 enables a Pay-Per-Frame (PPF) model that makes professional kinetics affordable for indie devs while providing a recurring revenue stream for actors. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animvoice-ledger-12-x402 Title: Phonic Flow · x402 Theme: Filmmaking & Animation (film-animation) · voice asset management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-stream licensing layer for voice assets. Animators pay $0.01 USDC to pull high-fidelity, metadata-enriched voice stems directly into their timeline. Every audition playback, download, or AI-training usage triggers an instant on-chain royalty to the voice actor. No subscriptions, just pay-per-take provenance. Why Hedera: By turning voice assets into x402-metered calls, we solve the 'audition theft' problem. Actors are compensated for the discovery phase, and studios avoid bulky licensing contracts by paying for exactly what ends up in the final render. Market: TAM $4.2B — The global voice-over and stock audio licensing industry transitioning to automated digital rights management. | SAM $850M — The independent animation and game development market requiring licensed vocal assets. | SOM $12M — Web3-native creators and AI-animation studios using Base for real-time asset procurement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Phonic Flow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-stream licensing layer for voice assets. Animators pay $0.01 USDC to pull high-fidelity, metadata-enriched voice stems directly into their timeline. Every audition playback, download, or AI-training usage triggers an instant on-chain royalty to the voice actor. No subscriptions, just pay-per-take provenance. Discipline: Filmmaking & Animation (voice asset management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning voice assets into x402-metered calls, we solve the 'audition theft' problem. Actors are compensated for the discovery phase, and studios avoid bulky licensing contracts by paying for exactly what ends up in the final render. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Phonic Flow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-vfxchain-sync-13-x402 Title: VFX-VOX · x402 Theme: Filmmaking & Animation (film-animation) · effects asset curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized VFX vault where every asset pull is a micro-transaction. Pay 0.01 USDC to instantly pull a production-ready effect layer, shader, or simulation cache directly into your timeline via IPFS. No subscriptions—just pay for the assets you actually render. Creators earn per-download royalties settled instantly on-chain. Why Hedera: By moving from a 'library access' model to a 'per-pull' model, high-end VFX boutique assets become accessible to indie creators while ensuring every asset usage is financially accounted for on the ledger. Market: TAM $18B — The global visual effects and 3D animation software market. | SAM $1.2B — Indie VFX studios and freelance motion designers seeking per-project asset costs. | SOM $45M — The growing 'asset-flip' and template economy for YouTube and TikTok creators. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VFX-VOX" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized VFX vault where every asset pull is a micro-transaction. Pay 0.01 USDC to instantly pull a production-ready effect layer, shader, or simulation cache directly into your timeline via IPFS. No subscriptions—just pay for the assets you actually render. Creators earn per-download royalties settled instantly on-chain. Discipline: Filmmaking & Animation (effects asset curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a 'library access' model to a 'per-pull' model, high-end VFX boutique assets become accessible to indie creators while ensuring every asset usage is financially accounted for on the ledger. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VFX-VOX" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animscript-pin-14-x402 Title: SceneGraph · x402 Theme: Filmmaking & Animation (film-animation) · script integration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-speed protocol for script-topgraphy. Every scene change, dialogue beat, or character note is indexed as an immutable branch. Animators and LLM-agents pay 0.01 USDC to pull the latest verified scene manifest, ensuring synchronous production without version-control friction. Payment is the heartbeat of the creative pipeline. Why Hedera: Script versioning in animation is chaotic; by turning every 'fetch' of a scene manifest into a micro-transaction, you create a self-funding coordination layer for studios and solo creators alike. Market: TAM $14.2B — The global animation and VFX production industry moving toward decentralized pipelines. | SAM $450M — The independent animation and pre-production software market. | SOM $12M — Web3-native animation guilds and AI-assisted storyboard agents utilizing Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SceneGraph" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-speed protocol for script-topgraphy. Every scene change, dialogue beat, or character note is indexed as an immutable branch. Animators and LLM-agents pay 0.01 USDC to pull the latest verified scene manifest, ensuring synchronous production without version-control friction. Payment is the heartbeat of the creative pipeline. Discipline: Filmmaking & Animation (script integration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Script versioning in animation is chaotic; by turning every 'fetch' of a scene manifest into a micro-transaction, you create a self-funding coordination layer for studios and solo creators alike. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SceneGraph" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animcollab-hub-15-x402 Title: FrameSync · x402 Theme: Filmmaking & Animation (film-animation) · team project syncing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A low-latency project synchronization layer where every frame commit, asset pull, and timeline sync is a discrete micro-transaction. Animators pay 0.01 USDC to broadcast state changes to the team; lead editors pay 0.01 USDC to finalize a scene branch. This eliminates the cost of heavy 'seat' licenses, shifting overhead to active production movements. Why Hedera: The x402 primitive replaces the subscription model with a 'pay-per-sync' heartbeat. This ensures that dormant projects cost zero, while high-velocity productions generate immediate, streaming revenue for the protocol, settled instantly via HTS transfer. Market: TAM $6.2B — The global animation and VFX software market currently locked behind rigid SaaS seat-pricing. | SAM $180M — The segment of boutiques and independent studios utilizing decentralized storage (IPFS/Filecoin) for collaborative pipelines. | SOM $12M — Early-moving Base-native animation collectives and AI-augmented storyboarding teams. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameSync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A low-latency project synchronization layer where every frame commit, asset pull, and timeline sync is a discrete micro-transaction. Animators pay 0.01 USDC to broadcast state changes to the team; lead editors pay 0.01 USDC to finalize a scene branch. This eliminates the cost of heavy 'seat' licenses, shifting overhead to active production movements. Discipline: Filmmaking & Animation (team project syncing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: The x402 primitive replaces the subscription model with a 'pay-per-sync' heartbeat. This ensures that dormant projects cost zero, while high-velocity productions generate immediate, streaming revenue for the protocol, settled instantly via HTS transfer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameSync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-storyboard-replay-16-x402 Title: DraftGate · x402 Theme: Filmmaking & Animation (film-animation) · animatic playback Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A Pay-Per-Play animatic engine. Creators host high-fidelity storyboards on IPFS, but playback is gated by x402. Production houses or fans sign a 0.01 USDC HTS transfer message to unlock a full-res timed sequence. Payment is the playback trigger: no subscription, just micro-metered access to pre-viz data. Why Hedera: Current animatic sharing relies on bulky video files or centralized links. By turning every 'Play' hit into a micro-transaction, creators can monetize their pre-production workflow directly. Agencies pay per-review session, ensuring the artist is compensated for every look at the IP. Market: TAM $4.2B — The global animation production pipeline and digital storyboarding software sector. | SAM $850M — The independent animation and commercial pre-visualization market transitioning to web3 asset ownership. | SOM $12M — Series-A phase boutique creative shops and freelance storyboard artists migrating to Base for client deliverables. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DraftGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A Pay-Per-Play animatic engine. Creators host high-fidelity storyboards on IPFS, but playback is gated by x402. Production houses or fans sign a 0.01 USDC HTS transfer message to unlock a full-res timed sequence. Payment is the playback trigger: no subscription, just micro-metered access to pre-viz data. Discipline: Filmmaking & Animation (animatic playback). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current animatic sharing relies on bulky video files or centralized links. By turning every 'Play' hit into a micro-transaction, creators can monetize their pre-production workflow directly. Agencies pay per-review session, ensuring the artist is compensated for every look at the IP. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DraftGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-modelpin-archive-17-x402 Title: ModelPin · x402 Theme: Filmmaking & Animation (film-animation) · 3D model documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An on-chain registry for 3D assets where every metadata fetch, preview render, or provenance check is an atomic 0.01 USDC transaction. Instead of a static database, it is a living ledger where studios and collectors pay per query to verify geometry integrity and licensing history. Each 'pin' is a cryptographically signed state, unlocked by an x402 stream, ensuring that high-fidelity documentation is monetized at the point of access. Why Hedera: By moving from a storage-heavy model (IPFS) to a logic-heavy payment model (x402), you turn documentation from an overhead cost into a recurring revenue stream. It prevents scraping and ensures that only authorized entities verify model technicals via micropayment. Market: TAM $1.2B — The global 3D modeling and digital twin market moving toward automated agent-based procurement. | SAM $140M — Professional 3D asset marketplaces and VFX technical directors requiring verifiable audit trails. | SOM $9M — Indie animation studios and Web3 game developers using Base to manage cross-platform asset interoperability. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ModelPin" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An on-chain registry for 3D assets where every metadata fetch, preview render, or provenance check is an atomic 0.01 USDC transaction. Instead of a static database, it is a living ledger where studios and collectors pay per query to verify geometry integrity and licensing history. Each 'pin' is a cryptographically signed state, unlocked by an x402 stream, ensuring that high-fidelity documentation is monetized at the point of access. Discipline: Filmmaking & Animation (3D model documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a storage-heavy model (IPFS) to a logic-heavy payment model (x402), you turn documentation from an overhead cost into a recurring revenue stream. It prevents scraping and ensures that only authorized entities verify model technicals via micropayment. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ModelPin" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animfont-locker-18-x402 Title: KernLogic · x402 Theme: Filmmaking & Animation (film-animation) · typography asset vault Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized type foundry where every glyph is a billable asset. Instead of bulky font licenses, motion designers pay 0.01 USDC per 'Glyph Call' to pull high-fidelity SVG/JSON animation paths directly into their timeline. Facilitator settles the usage fee, returning a Hedera transaction id that acts as the cryptographic proof-of-license for the rendered frame. Perfect for dynamic credit sequences and real-time captioning bots. Why Hedera: Moving from 'storage' to 'metered usage' aligns with the high-frequency nature of animation keyframes. It transforms a static vault into a living API for generative typography. Market: TAM $4.2B — The global creative software and typography licensing market. | SAM $850M — The digital typeface and motion graphics asset market transitioning to granular licensing. | SOM $12M — Independent motion designers and automated social video captioning agents on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KernLogic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized type foundry where every glyph is a billable asset. Instead of bulky font licenses, motion designers pay 0.01 USDC per 'Glyph Call' to pull high-fidelity SVG/JSON animation paths directly into their timeline. Facilitator settles the usage fee, returning a Hedera transaction id that acts as the cryptographic proof-of-license for the rendered frame. Perfect for dynamic credit sequences and real-time captioning bots. Discipline: Filmmaking & Animation (typography asset vault). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'storage' to 'metered usage' aligns with the high-frequency nature of animation keyframes. It transforms a static vault into a living API for generative typography. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KernLogic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-lipsync-ledger-19-x402 Title: Phoneme · x402 Theme: Filmmaking & Animation (film-animation) · lipsync data storage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An API-first storage layer for pre-computed phoneme and viseme data. Animators and generative video agents pay 0.01 USDC to 'GET' precise timing data for specific dialogue strings, eliminating redundant compute. Payment triggers the instant decryption of the sync-key from IPFS. Why Hedera: Animation is compute-heavy. By turn-keying the 'sync' data itself as a paid primitive, we shift the value from the render to the underlying timing metadata. x402 allows agents to autonomously 'buy' the syllables they need to speak in real-time. Market: TAM $3.2B — The global 3D animation and specialized character rigging software market. | SAM $480M — The emerging market for AI-generated video, VTubing, and automated dubbing services requiring real-time viseme mapping. | SOM $12M — Independent 3D animators and 'AI-Influencer' developers on Hedera seeking low-latency, pay-as-you-go sync assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Phoneme" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An API-first storage layer for pre-computed phoneme and viseme data. Animators and generative video agents pay 0.01 USDC to 'GET' precise timing data for specific dialogue strings, eliminating redundant compute. Payment triggers the instant decryption of the sync-key from IPFS. Discipline: Filmmaking & Animation (lipsync data storage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Animation is compute-heavy. By turn-keying the 'sync' data itself as a paid primitive, we shift the value from the render to the underlying timing metadata. x402 allows agents to autonomously 'buy' the syllables they need to speak in real-time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Phoneme" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animshot-library-20-x402 Title: CineArchive · x402 Theme: Filmmaking & Animation (film-animation) · shot list archival Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A globally distributed, pay-per-frame Shot Registry. Filmmakers pay $0.01 per JSON manifest commit to Base, anchoring creative intent to IPFS. Production houses and distributors pay to query 'Golden-Master' shot lists, ensuring the audit trail from pre-vis to post matches the immutable ledger. Payments meter the archival process, turning metadata into a high-integrity asset class. Why Hedera: Shifts archival from a passive cost to a 'proof-of-work' primitive. By metering the manifest commit, the app prevents spam and ensures every shot entry is a deliberate, paid financial event that proves provenance for insurance and distribution audits. Market: TAM $1.4B — The global film production and digital asset management (DAM) market, integrated with agentic post-production pipelines. | SAM $180M — Independent producers and boutique animation studios transitioning to transparent, blockchain-based chain of title. | SOM $9M — The high-end indie film circuit and commercial production houses using Base/Tableland for verifiable metadata. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CineArchive" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A globally distributed, pay-per-frame Shot Registry. Filmmakers pay $0.01 per JSON manifest commit to Base, anchoring creative intent to IPFS. Production houses and distributors pay to query 'Golden-Master' shot lists, ensuring the audit trail from pre-vis to post matches the immutable ledger. Payments meter the archival process, turning metadata into a high-integrity asset class. Discipline: Filmmaking & Animation (shot list archival). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts archival from a passive cost to a 'proof-of-work' primitive. By metering the manifest commit, the app prevents spam and ensures every shot entry is a deliberate, paid financial event that proves provenance for insurance and distribution audits. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CineArchive" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-propchain-share-21-x402 Title: MasterKey · x402 Theme: Filmmaking & Animation (film-animation) · prop design collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Design, commit, and fork film-ready prop schematics. Every version history write to IPFS and every 'inspect' of high-fidelity files requires a micropayment. Directors pay to unlock the master file, while contributors earn a stream of USDC for every iteration referenced in the production pipeline. Why Hedera: Traditional asset management is a lump-sum subscription model. This reframes prop design as a granular, pay-per-interaction ledger. By metering 'v-history' lookups and 'forking' actions, the app ensures that the original designer is compensated for every step of the creative evolution. Market: TAM $4.8B — The global film production software and virtual asset management market. | SAM $350M — Independent animation studios and decentralized film collectives transitioning to remote, asset-heavy production workflows. | SOM $12M — Early-adopter visual artists and 'crypto-cinema' productions using Base for transparent budget distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MasterKey" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Design, commit, and fork film-ready prop schematics. Every version history write to IPFS and every 'inspect' of high-fidelity files requires a micropayment. Directors pay to unlock the master file, while contributors earn a stream of USDC for every iteration referenced in the production pipeline. Discipline: Filmmaking & Animation (prop design collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional asset management is a lump-sum subscription model. This reframes prop design as a granular, pay-per-interaction ledger. By metering 'v-history' lookups and 'forking' actions, the app ensures that the original designer is compensated for every step of the creative evolution. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MasterKey" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-storyboard-captioner-22-x402 Title: FinalScript · x402 Theme: Filmmaking & Animation (film-animation) · dialogue annotation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — A production-grade metadata layer for animators. Pay a single cent per frame to cryptographically anchor dialogue tracks to IPFS-hosted storyboard panels. Every sync generates an immutable on-chain timestamp, ensuring that script versioning and frame alignment are physically inseparable and provable during pre-vis. Studio-ready, pay-per-frame precision. Why Hedera: By turning dialogue anchoring into a pay-per-action event, we convert a mundane task into a verified production milestone. Using x402 allows for granular cost tracking of 'Captioning Labor' directly on the Base ledger, providing a transparent audit trail for animation studios. Market: TAM $950M — The global technical animation and script-to-screen metadata market. | SAM $140M — The growing independent animation and pre-visualization sector leveraging decentralized storage and micro-budgeting. | SOM $18M — Individual storyboard artists and small indie studios migrating to Base-native production tools for IP protection. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FinalScript" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — A production-grade metadata layer for animators. Pay a single cent per frame to cryptographically anchor dialogue tracks to IPFS-hosted storyboard panels. Every sync generates an immutable on-chain timestamp, ensuring that script versioning and frame alignment are physically inseparable and provable during pre-vis. Studio-ready, pay-per-frame precision. Discipline: Filmmaking & Animation (dialogue annotation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning dialogue anchoring into a pay-per-action event, we convert a mundane task into a verified production milestone. Using x402 allows for granular cost tracking of 'Captioning Labor' directly on the Base ledger, providing a transparent audit trail for animation studios. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FinalScript" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animframe-swap-23-x402 Title: CellState · x402 Theme: Filmmaking & Animation (film-animation) · frame trading marketplace Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A frame-by-frame monetization engine for rogue animators. Instead of bulk-releasing a short, animators stream-drop frames to a high-frequency marketplace. Users pay 0.01 USDC to 'claim-verify' the provenance of a single frame on-chain. Each micro-transaction acts as a vote for which sequence branch the animator should render next, turning passive viewing into a paid, collaborative render-stream. Why Hedera: Traditional NFT minting is too heavy for individual animation frames (24fps). x402 enables 'streaming ownership' where the cost of verification matches the granular nature of the asset. The payment isn't just a fee; it's the primitive that triggers the IPFS pinning and provenance event via the facilitator. Market: TAM $12B — The global animation and VFX industry shifting toward decentralized asset ownership and agentic content consumption. | SAM $420M — The digital collectibles and animation merchandise market transition to on-chain provenance. | SOM $12M — Indie animators on X/TikTok using micropayments to fund production via frame-by-frame 'claim' events. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CellState" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A frame-by-frame monetization engine for rogue animators. Instead of bulk-releasing a short, animators stream-drop frames to a high-frequency marketplace. Users pay 0.01 USDC to 'claim-verify' the provenance of a single frame on-chain. Each micro-transaction acts as a vote for which sequence branch the animator should render next, turning passive viewing into a paid, collaborative render-stream. Discipline: Filmmaking & Animation (frame trading marketplace). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT minting is too heavy for individual animation frames (24fps). x402 enables 'streaming ownership' where the cost of verification matches the granular nature of the asset. The payment isn't just a fee; it's the primitive that triggers the IPFS pinning and provenance event via the facilitator. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CellState" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-storyboard-sync-0-x402 Title: FrameFlow · x402 Theme: Filmmaking & Animation (film-animation) · storyboard collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity storyboard engine where every frame-level feedback, version fork, or AI-assisted sketch is a $0.01 micro-transaction. Filmmakers pay for surgical precision, and artists earn liquid USDC for every real-time revision approved. Stop the friction of monthly seat-pricing; pay only for the frames that make the final cut. Why Hedera: By turning feedback and versioning into a pay-per-use primitive, the app eliminates the 'infinite revision' trap. It aligns incentives between the director (who wants quality) and the artist (who gets paid for every iteration), utilizing Base for sub-cent settlement efficiency. Market: TAM $3.1B — Global animation and VFX pre-production market transitioning to real-time collaborative toolsets. | SAM $450M — The addressable market for indie animation studios and freelance storyboarders ready for pay-on-delivery models. | SOM $12M — Early-stage pre-production cycles for decentralized film funds and Base-native creative collectives. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity storyboard engine where every frame-level feedback, version fork, or AI-assisted sketch is a $0.01 micro-transaction. Filmmakers pay for surgical precision, and artists earn liquid USDC for every real-time revision approved. Stop the friction of monthly seat-pricing; pay only for the frames that make the final cut. Discipline: Filmmaking & Animation (storyboard collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning feedback and versioning into a pay-per-use primitive, the app eliminates the 'infinite revision' trap. It aligns incentives between the director (who wants quality) and the artist (who gets paid for every iteration), utilizing Base for sub-cent settlement efficiency. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animatic-drops-1-x402 Title: FLIPBOOK · x402 Theme: Filmmaking & Animation (film-animation) · animatic distribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless distribution engine for high-end storyboards. Creators gate high-fidelity animatics behind 0.01 USDC x402 signatures. Studios or fans pay per frame-sequence viewed, instantly settling via HTS transfer. No subscriptions; pay-per-scene consumption for independent pilots. Why Hedera: Transitioning from 'sponsored previews' to 'micro-metered access' ensures creators are paid for the exact depth of engagement. Each scene transition triggers a sub-penny settlement, turning the viewing experience into a real-time revenue stream via Base. Market: TAM $32B — Global digital animation and storyboard software ecosystem. | SAM $450M — The independent animation and pilot-pitching market migrating to web3 rails. | SOM $12M — Web3-native animation houses and crowdfunded pilots utilizing metered distribution on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FLIPBOOK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless distribution engine for high-end storyboards. Creators gate high-fidelity animatics behind 0.01 USDC x402 signatures. Studios or fans pay per frame-sequence viewed, instantly settling via HTS transfer. No subscriptions; pay-per-scene consumption for independent pilots. Discipline: Filmmaking & Animation (animatic distribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from 'sponsored previews' to 'micro-metered access' ensures creators are paid for the exact depth of engagement. Each scene transition triggers a sub-penny settlement, turning the viewing experience into a real-time revenue stream via Base. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FLIPBOOK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-motion-share-2-x402 Title: Spline · x402 Theme: Filmmaking & Animation (film-animation) · motion design sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn motion design into a liquid asset. 'Spline' allows creators to publish keyframe animations behind a 0.01 USDC unlock. Feedback isn't just a comment; it's a paid ‘mark-up’ that rewards the creator. Motion designers get paid for every view/scrub, fueling a high-velocity library for professional animators. Why Hedera: By shifting the model from 'free-to-view' to 'pay-per-scrub/unlock,' we eliminate low-effort feedback and provide a direct revenue stream for artists. The x402 protocol ensures that even a tiny micro-interaction (like downloading a JSON Lottie file) is monetized instantly. Market: TAM $22B — The global animation and VFX software market shifting toward micro-licensing and agentic asset procurement. | SAM $1.2B — Professional motion designers and studio leads looking for premium, paid inspiration and feedback loops. | SOM $8.5M — Early adopters in the Lottie/After Effects community utilizing Base for micro-sales of animation presets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Spline" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn motion design into a liquid asset. 'Spline' allows creators to publish keyframe animations behind a 0.01 USDC unlock. Feedback isn't just a comment; it's a paid ‘mark-up’ that rewards the creator. Motion designers get paid for every view/scrub, fueling a high-velocity library for professional animators. Discipline: Filmmaking & Animation (motion design sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting the model from 'free-to-view' to 'pay-per-scrub/unlock,' we eliminate low-effort feedback and provide a direct revenue stream for artists. The x402 protocol ensures that even a tiny micro-interaction (like downloading a JSON Lottie file) is monetized instantly. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Spline" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-frame-rights-3-x402 Title: Stills · x402 Theme: Filmmaking & Animation (film-animation) · frame licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity rendering API for animators and digital artists. Instead of bulk licensing, users pay 0.01 USDC to unlock the raw vector source or commercial usage rights for a single frame. Perfect for storyboarders, remixers, and AI training sets requiring granular provenance. Each frame access triggers an on-chain receipt, turning every frame into a liquid asset. Why Hedera: By shifting from 'ownership' to 'per-frame access,' animators can monetize at the granular level. x402 eliminates the friction of traditional licensing contracts, allowing for 'pay-per-frame' rendering or downloading that is cryptographically verifiable. Market: TAM $18B — The global animation and VFX outsourcing industry. | SAM $420M — The creative asset licensing and stock footage market transitioning to micro-transactions. | SOM $12M — Independent animators and boutique studios using Base for automated asset distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stills" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity rendering API for animators and digital artists. Instead of bulk licensing, users pay 0.01 USDC to unlock the raw vector source or commercial usage rights for a single frame. Perfect for storyboarders, remixers, and AI training sets requiring granular provenance. Each frame access triggers an on-chain receipt, turning every frame into a liquid asset. Discipline: Filmmaking & Animation (frame licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'ownership' to 'per-frame access,' animators can monetize at the granular level. x402 eliminates the friction of traditional licensing contracts, allowing for 'pay-per-frame' rendering or downloading that is cryptographically verifiable. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stills" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-voice-sync-4-x402 Title: VoxMeter · x402 Theme: Filmmaking & Animation (film-animation) · voice-over integration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-syllable ADR. A headless engine that gates high-fidelity voice-over stems behind 0.01 USDC micro-transfers. Producers pay to audition takes; voice actors receive instant settlement upon audio unlock. Every sync event is a signed HTS transfer transaction, turning amateur voice logs into a metered, professional asset library. Why Hedera: Moving from 'collaboration' to 'metered access' solves the freelance payment friction. By charging 0.01 USDC per audition or sync-point, the developer creates a high-velocity transaction environment suitable for AI-to-Human talent sourcing. Market: TAM $2.4B — The global voice-over and automated dubbing market transitioning to agentic, on-demand labor. | SAM $85M — Independent animation houses and remote ADR studios leveraging micro-payment workflows. | SOM $1.2M — Individual voice talent and 'Fiverr-style' creators on Hedera seeking instant escrow-free payment. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VoxMeter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-syllable ADR. A headless engine that gates high-fidelity voice-over stems behind 0.01 USDC micro-transfers. Producers pay to audition takes; voice actors receive instant settlement upon audio unlock. Every sync event is a signed HTS transfer transaction, turning amateur voice logs into a metered, professional asset library. Discipline: Filmmaking & Animation (voice-over integration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'collaboration' to 'metered access' solves the freelance payment friction. By charging 0.01 USDC per audition or sync-point, the developer creates a high-velocity transaction environment suitable for AI-to-Human talent sourcing. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VoxMeter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-anim-rights-vault-5-x402 Title: CellGuard · x402 Theme: Filmmaking & Animation (film-animation) · animation IP protection Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn animation assets into pay-per-view or pay-per-license digital primitives. Every time a studio, fan, or AI model accesses a frame, rig, or storyboard, they stream 0.01 USDC. Payment is the key that unlocks high-res preview and metadata, ensuring every interaction is a revenue event tracked on Hedera. Why Hedera: Animation IP is often stolen or leaked before distribution. By gating the 'Right to View' and 'Right to Use' behind x402 micropayments, creators move from passive protection to active monetization of their creative process. Ownership is proven via the transaction ledger. Market: TAM $390B — The global animation and VFX market moving toward granular asset-level licensing. | SAM $1.2B — Independent animators and boutique production houses seeking sub-licensing revenue. | SOM $45M — Web3-native animators and AI artists using Base for asset distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CellGuard" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn animation assets into pay-per-view or pay-per-license digital primitives. Every time a studio, fan, or AI model accesses a frame, rig, or storyboard, they stream 0.01 USDC. Payment is the key that unlocks high-res preview and metadata, ensuring every interaction is a revenue event tracked on Hedera. Discipline: Filmmaking & Animation (animation IP protection). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Animation IP is often stolen or leaked before distribution. By gating the 'Right to View' and 'Right to Use' behind x402 micropayments, creators move from passive protection to active monetization of their creative process. Ownership is proven via the transaction ledger. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CellGuard" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-render-economy-6-x402 Title: FRAMESET · x402 Theme: Filmmaking & Animation (film-animation) · distributed rendering Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular rendering marketplace where animation frames are treated as unit-commodities. Creators pay 0.01 USDC per frame rendered by a distributed node network via HTS transfer. No subscriptions or bulk credits—just pure, pay-per-frame settlement that allows hobbyists to clear small batches and studios to burst-render at scale without overhead. Render workers receive instant, per-frame micro-settlements, turning idle GPU cycles into real-time USDC streams. Why Hedera: Traditional render farms have high entry thresholds ($50 minimums) and opaque pricing. By normalizing all rendering tasks to a 0.01 USDC base unit per frame (or complexity unit), we create a high-velocity 'render-as-you-go' economy ideal for the growing indie animation and AI-video sectors. Market: TAM $35B — The global 3D visualization and animation software ecosystem transitioning to cloud-native workflows. | SAM $4.8B — The cloud rendering services market for independent studios and freelance 3D artists. | SOM $85M — Real-time frame settlement for decentralized animation collectives and AI-driven video synthesis workers on Hedera.高速渲染. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FRAMESET" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular rendering marketplace where animation frames are treated as unit-commodities. Creators pay 0.01 USDC per frame rendered by a distributed node network via HTS transfer. No subscriptions or bulk credits—just pure, pay-per-frame settlement that allows hobbyists to clear small batches and studios to burst-render at scale without overhead. Render workers receive instant, per-frame micro-settlements, turning idle GPU cycles into real-time USDC streams. Discipline: Filmmaking & Animation (distributed rendering). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional render farms have high entry thresholds ($50 minimums) and opaque pricing. By normalizing all rendering tasks to a 0.01 USDC base unit per frame (or complexity unit), we create a high-velocity 'render-as-you-go' economy ideal for the growing indie animation and AI-video sectors. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FRAMESET" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-loop-provenance-7-x402 Title: LoopTrace · x402 Theme: Filmmaking & Animation (film-animation) · loop animation tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micropayment primitive for verifying the lineage of looped assets. Every frame modification, layer tweak, or metadata update requires a 0.01 USDC x402 signature to write to the provenance log. This turns 'saving' into a permanent, paid attestation of creative labor, ensuring that when a loop goes viral, the forensic history is fully settled and immutable. Pay per state-sync to secure your IP at the source. Why Hedera: By gatekeeping the 'write' action (provenance) behind a 0.01 USDC micropayment, the app prevents spam while creating a high-fidelity audit trail. In professional animation workflows, paying fractions of a cent per save is a negligible cost for verifiable proof of original work in the age of generative AI. Market: TAM $4.2B — The global animation and VFX industry shifting toward decentralized ownership models. | SAM $850M — The digital content provenance and forensic watermarking sector. | SOM $12M — Professional 2D/3D animators and studios using Base for asset-level IP protection. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LoopTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micropayment primitive for verifying the lineage of looped assets. Every frame modification, layer tweak, or metadata update requires a 0.01 USDC x402 signature to write to the provenance log. This turns 'saving' into a permanent, paid attestation of creative labor, ensuring that when a loop goes viral, the forensic history is fully settled and immutable. Pay per state-sync to secure your IP at the source. Discipline: Filmmaking & Animation (loop animation tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By gatekeeping the 'write' action (provenance) behind a 0.01 USDC micropayment, the app prevents spam while creating a high-fidelity audit trail. In professional animation workflows, paying fractions of a cent per save is a negligible cost for verifiable proof of original work in the age of generative AI. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LoopTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-pitchchain-8-x402 Title: ScriptGuard · x402 Theme: Filmmaking & Animation (film-animation) · film pitch verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A zero-friction ledger for script and storyboard submissions where studios pay 0.01 USDC to 'Open' a pitch. This instant micropayment triggers a timestamped read-receipt and proof-of-review, ensuring creators are compensated for their IP exposure while protecting studios from liability. Why Hedera: By moving from 'gasless tracking' to 'paid verification,' the act of reviewing a pitch becomes a metered financial event. This professionalizes the handshake and creates a high-integrity audit trail for IP disputes. Market: TAM $2.8B — The global film & TV pre-production and talent agency sector. | SAM $450M — The creative development and scripted digital media acquisition market. | SOM $12M — Independent screenwriters and boutique animation studios using Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScriptGuard" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A zero-friction ledger for script and storyboard submissions where studios pay 0.01 USDC to 'Open' a pitch. This instant micropayment triggers a timestamped read-receipt and proof-of-review, ensuring creators are compensated for their IP exposure while protecting studios from liability. Discipline: Filmmaking & Animation (film pitch verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'gasless tracking' to 'paid verification,' the act of reviewing a pitch becomes a metered financial event. This professionalizes the handshake and creates a high-integrity audit trail for IP disputes. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScriptGuard" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-anim-badge-9-x402 Title: RigCheck · x402 Theme: Filmmaking & Animation (film-animation) · skill certification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-frame verification for technical animation mastery. Animators pay 0.01 USDC to trigger an automated rig-check and edge-flow audit. Success mints a non-transferable skill-sig directly to their Base wallet. Studios pay per-lookup to query a creator's real-time competence ledger. No monthly subs, just pay for the proof you need to land the gig. Why Hedera: Moving away from 'free badges' to 'paid proof' creates a sybil-resistant certification layer. The x402 model turns the validation process into a micro-service where the cost of verification is offloaded to the user or his future employer, ensuring only serious craft is on-chain. Market: TAM $2.8B — The total addressable market for automated technical certification and AI-assisted creative auditing. | SAM $450M — The global freelance animation and VFX vetting market. | SOM $12M — Professional 3D technical artists and riggers transacting on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RigCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-frame verification for technical animation mastery. Animators pay 0.01 USDC to trigger an automated rig-check and edge-flow audit. Success mints a non-transferable skill-sig directly to their Base wallet. Studios pay per-lookup to query a creator's real-time competence ledger. No monthly subs, just pay for the proof you need to land the gig. Discipline: Filmmaking & Animation (skill certification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving away from 'free badges' to 'paid proof' creates a sybil-resistant certification layer. The x402 model turns the validation process into a micro-service where the cost of verification is offloaded to the user or his future employer, ensuring only serious craft is on-chain. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RigCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-framechain-10-x402 Title: CineProof · x402 Theme: Filmmaking & Animation (film-animation) · frame-by-frame tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: High-fidelity frame verification for high-stakes post-production. Every 'Track & Verify' action costs 0.01 USDC, instantly anchoring a motion-vector hash to Base. Ensure legal-grade proof of human authorship or AI-augmentation on a per-frame basis. Pay only for the frames you audit. Why Hedera: Motion tracking and rotoscoping are granular, repetitive tasks. By making 'Verification' a micropayment event, you turn a passive logging system into an immutable audit trail where the value (0.01 USDC) validates the creative labor behind each individual frame. Market: TAM $2.4B — Global cloud rendering and digital animation production pipelines. | SAM $420M — Professional VFX houses and independent animation studios adopting crypto-native audit trails. | SOM $12M — Freelance rotoscoping artists and AI-video verify-to-earn workflows. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CineProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT High-fidelity frame verification for high-stakes post-production. Every 'Track & Verify' action costs 0.01 USDC, instantly anchoring a motion-vector hash to Base. Ensure legal-grade proof of human authorship or AI-augmentation on a per-frame basis. Pay only for the frames you audit. Discipline: Filmmaking & Animation (frame-by-frame tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Motion tracking and rotoscoping are granular, repetitive tasks. By making 'Verification' a micropayment event, you turn a passive logging system into an immutable audit trail where the value (0.01 USDC) validates the creative labor behind each individual frame. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CineProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animateswap-11-x402 Title: CUTSET · x402 Theme: Filmmaking & Animation (film-animation) · asset trading Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-velocity asset clearinghouse for motion designers. Every rigged limb, walk cycle, and texture map is gated by a 0.01 USDC x402 signature. Creators earn per-download settlement, while animators kitbash scenes for cents without subscription bloat. Social sharing includes a 'Buy Frame' deep link to instantly authorize a micro-transfer and pull the asset into your timeline. Why Hedera: Legacy marketplaces force $20+ bundles for one useful asset. x402 enables 'atomic asset trading' where the payment acts as the granular decryption key for individual primitive files. Market: TAM $12.5B — The global 3D animation and digital asset motion graphics market. | SAM $420M — The independent animation and game dev asset-store market shifting to micro-licensing models. | SOM $11M — Fractional asset sales for indie creators and AI-driven animation agents on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CUTSET" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-velocity asset clearinghouse for motion designers. Every rigged limb, walk cycle, and texture map is gated by a 0.01 USDC x402 signature. Creators earn per-download settlement, while animators kitbash scenes for cents without subscription bloat. Social sharing includes a 'Buy Frame' deep link to instantly authorize a micro-transfer and pull the asset into your timeline. Discipline: Filmmaking & Animation (asset trading). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Legacy marketplaces force $20+ bundles for one useful asset. x402 enables 'atomic asset trading' where the payment acts as the granular decryption key for individual primitive files. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CUTSET" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-loopsync-12-x402 Title: PulseSync · x402 Theme: Filmmaking & Animation (film-animation) · looped animation synchronization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A low-latency synchronization engine for collaborative animation. Pay 0.01 USDC to broadcast a global clock pulse that forces all connected peers to sync their playheads to the millisecond. Metered framing: pay per sync-shot. Seamlessly coordinate complex multi-track visual loops across decentralized creative teams by turning the master-clock into a programmable micro-transaction. Why Hedera: By shifting from 'state management' to 'paid pulses', synchronization becomes a deliberate, verifiable action. x402 eliminates the friction of subscription-based creative suites, allowing teams to pay only for the precise duration of their collaborative session. Market: TAM $2.8B — The global animation and VFX software market shifting toward remote-first, granular billing models. | SAM $450M — The emerging 'Web3 Creative' sector requiring real-time sub-cent coordination for remote playback. | SOM $12M — Individual digital animators and NFT studios using Base for collaborative asset production. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PulseSync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A low-latency synchronization engine for collaborative animation. Pay 0.01 USDC to broadcast a global clock pulse that forces all connected peers to sync their playheads to the millisecond. Metered framing: pay per sync-shot. Seamlessly coordinate complex multi-track visual loops across decentralized creative teams by turning the master-clock into a programmable micro-transaction. Discipline: Filmmaking & Animation (looped animation synchronization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'state management' to 'paid pulses', synchronization becomes a deliberate, verifiable action. x402 eliminates the friction of subscription-based creative suites, allowing teams to pay only for the precise duration of their collaborative session. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PulseSync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-designtoken-flow-13-x402 Title: RenderStream · x402 Theme: Filmmaking & Animation (film-animation) · tokenized asset flow Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Asset Call. Pay-per-frame usage for premium 3D models and animation rigs. High-fidelity assets are no longer sold as static packs; their primitives are metered. Every time a scene renders or an asset is pulled into the viewport, a x402 signature triggers a streaming micropayment to the creator. No subscriptions, just a direct flow from the animator's render queue to the designer's wallet. Why Hedera: Traditional asset marketplaces suffer from 'all-you-can-eat' leakage or high entry barriers. By shifting to a pay-per-use model via HTS transfer, we create a fluid economy where high-end assets are affordable for indie creators while generating passive, high-frequency revenue for top-tier studios based on actual utilization. Market: TAM $18B — The global digital asset and character animation software market moving toward cloud-integrated production. | SAM $850M — The high-end 3D model and stock animation market seeking granular usage tracking. | SOM $12M — Indie animators and boutique motion studios on Hedera using automated pipelines. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RenderStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Asset Call. Pay-per-frame usage for premium 3D models and animation rigs. High-fidelity assets are no longer sold as static packs; their primitives are metered. Every time a scene renders or an asset is pulled into the viewport, a x402 signature triggers a streaming micropayment to the creator. No subscriptions, just a direct flow from the animator's render queue to the designer's wallet. Discipline: Filmmaking & Animation (tokenized asset flow). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional asset marketplaces suffer from 'all-you-can-eat' leakage or high entry barriers. By shifting to a pay-per-use model via HTS transfer, we create a fluid economy where high-end assets are affordable for indie creators while generating passive, high-frequency revenue for top-tier studios based on actual utilization. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RenderStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animcrew-chain-14-x402 Title: DOPAMINE · x402 Theme: Filmmaking & Animation (film-animation) · team collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Precision frame-by-frame collaboration. Every time a lead animator approves a sequence, pushes a render to the cloud, or reviews a peer's rigging, 0.01 USDC is instantly streamed from the production budget to the contributor's Magic Link email sign-in. No manual invoicing or monthly payroll friction; the film's progress is a series of metered micro-milestones settled on-chain. Why Hedera: Traditional animation pipelines suffer from 'delayed payment fatigue' and opaque contribution tracking. x402 enables granular, task-level compensation—paying a rigger per joint or a colorist per frame—transforming production into a high-velocity, pay-per-effort machine. Market: TAM $28B — The total addressable market for global animation, VFX, and game development services. | SAM $420M — The global freelance animation and VFX production market. | SOM $15M — Independent animated features and boutique VFX houses adopting micro-task workflows on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DOPAMINE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Precision frame-by-frame collaboration. Every time a lead animator approves a sequence, pushes a render to the cloud, or reviews a peer's rigging, 0.01 USDC is instantly streamed from the production budget to the contributor's Magic Link email sign-in. No manual invoicing or monthly payroll friction; the film's progress is a series of metered micro-milestones settled on-chain. Discipline: Filmmaking & Animation (team collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional animation pipelines suffer from 'delayed payment fatigue' and opaque contribution tracking. x402 enables granular, task-level compensation—paying a rigger per joint or a colorist per frame—transforming production into a high-velocity, pay-per-effort machine. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DOPAMINE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-styleswap-15-x402 Title: VibeCast · x402 Theme: Filmmaking & Animation (film-animation) · animation style exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A generative animation workbench where every style transfer is a micro-transaction. Prompt an animation, then pay 0.01 USDC to 'drain' the weights of a pro-grade style model (Noir, Ghibli, Claymation) directly into your workflow. Creators earn per-frame royalties as their custom LoRAs are utilized by the community via HTS transfer signed authorizations. No subscriptions, just high-fidelity style injection on demand. Why Hedera: By moving away from 'free/sponsored' models to x402, you create a direct incentive for high-end animators to upload their custom styles. The 0.01 USDC fee acts as a granular royalty stream, making style-remixing a sustainable digital commodity rather than a subsidized cost. Market: TAM $3.2B — The global animation and VFX software market shifting toward pay-per-render cloud models. | SAM $450M — The segment of independent animators and social media content creators using AI-assisted pipelines. | SOM $12M — Early adopters on Hedera seeking high-end animation aesthetic consistent with the 'Onchain Summer' creator economy. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VibeCast" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A generative animation workbench where every style transfer is a micro-transaction. Prompt an animation, then pay 0.01 USDC to 'drain' the weights of a pro-grade style model (Noir, Ghibli, Claymation) directly into your workflow. Creators earn per-frame royalties as their custom LoRAs are utilized by the community via HTS transfer signed authorizations. No subscriptions, just high-fidelity style injection on demand. Discipline: Filmmaking & Animation (animation style exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving away from 'free/sponsored' models to x402, you create a direct incentive for high-end animators to upload their custom styles. The 0.01 USDC fee acts as a granular royalty stream, making style-remixing a sustainable digital commodity rather than a subsidized cost. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VibeCast" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-voicechain-cast-16-x402 Title: VOX LOCK · x402 Theme: Filmmaking & Animation (film-animation) · cast rights tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-licensing layer for voice actors. Animation studios use an HTS transfer signed request to pay exactly 0.01 USDC per second of processed audio or per character-dialogue block. The fee triggers an instant settlement to the voice talent's wallet, turning performance rights from a legal hurdle into a metered utility. Why Hedera: Moving from 'tracking' (passive) to 'metered usage' (active). By making payment the trigger for audio access/rendering, you eliminate royalty disputes and provide talent with real-time streaming income. Market: TAM $3.4B — The worldwide animation and game production industry. | SAM $850M — The global voice-over and dubbing market transitioning to digital delivery and AI-augmented production. | SOM $12M — Independent animation studios and high-fidelity AI-character developers requiring instant, legal voice-sync rights on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VOX LOCK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-licensing layer for voice actors. Animation studios use an HTS transfer signed request to pay exactly 0.01 USDC per second of processed audio or per character-dialogue block. The fee triggers an instant settlement to the voice talent's wallet, turning performance rights from a legal hurdle into a metered utility. Discipline: Filmmaking & Animation (cast rights tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'tracking' (passive) to 'metered usage' (active). By making payment the trigger for audio access/rendering, you eliminate royalty disputes and provide talent with real-time streaming income. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VOX LOCK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animchain-feedback-17-x402 Title: FrameCheck · x402 Theme: Filmmaking & Animation (film-animation) · creative feedback loops Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Streamline the animation review cycle by charging a flat 0.01 USDC per frame-locked critique. Directors pay a micropayment to sign a critique into the immutable production log; animators pay to 'resolve' and clear the flag. No gas, just friction-less creative accountability via HTS transfer. Why Hedera: Feedback is often ignored or lost in long-form threads. By attaching a micro-cost to every 'critique' and 'resolve' action, the production team creates a high-signal audit trail where every creative pivot is a settled transaction, ensuring animators are compensated for specific re-work requests. Market: TAM $45B — The global animation, VFX, and video games market. | SAM $1.2B — The total addressable outsourced animation and VFX services market transitioning to remote/on-chain coordination. | SOM $8.5M — Independent animation studios and boutique VFX houses using Base for production management. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Streamline the animation review cycle by charging a flat 0.01 USDC per frame-locked critique. Directors pay a micropayment to sign a critique into the immutable production log; animators pay to 'resolve' and clear the flag. No gas, just friction-less creative accountability via HTS transfer. Discipline: Filmmaking & Animation (creative feedback loops). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Feedback is often ignored or lost in long-form threads. By attaching a micro-cost to every 'critique' and 'resolve' action, the production team creates a high-signal audit trail where every creative pivot is a settled transaction, ensuring animators are compensated for specific re-work requests. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-metascene-ledger-18-x402 Title: MetaCut · x402 Theme: Filmmaking & Animation (film-animation) · scene metadata tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity audit trail for production pipelines. Every scene version, lighting tweak, and rig change is an immutable commit. Producers pay $0.01 per metadata synchronization to lock in provenance, while render farms pay-per-verification to ensure they are processing the latest signed scene state. No subscriptions, just a metered ledger for professional animation integrity. Why Hedera: By turning metadata commits into a pay-per-use primitive, the app eliminates the 'hidden' costs of data corruption and versioning conflicts in large studios. It aligns the cost of production directly with the volume of creative iterations. Market: TAM $14.5B — The total addressable market for global animation and VFX production pipelines integrating automated version control. | SAM $850M — The global 3D animation software and metadata management market for independent studios and agencies. | SOM $12M — The immediate niche of decentralized animation collectives and Web3 cinema production houses requiring verifiable provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MetaCut" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity audit trail for production pipelines. Every scene version, lighting tweak, and rig change is an immutable commit. Producers pay $0.01 per metadata synchronization to lock in provenance, while render farms pay-per-verification to ensure they are processing the latest signed scene state. No subscriptions, just a metered ledger for professional animation integrity. Discipline: Filmmaking & Animation (scene metadata tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning metadata commits into a pay-per-use primitive, the app eliminates the 'hidden' costs of data corruption and versioning conflicts in large studios. It aligns the cost of production directly with the volume of creative iterations. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MetaCut" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-clipchain-share-19-x402 Title: FrameDrop · x402 Theme: Filmmaking & Animation (film-animation) · clip sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity animation repository where viewing is a transaction. Creators upload raw clips; users pay $0.01 per high-res view or metadata scrape. Every 'share' is a signed HTS transfer intent, allowing animation enthusiasts and AI video-trainers to buy authenticated frames directly from the artist's wallet. Pay-per-frame consumption replaces ad-supported hosting. Why Hedera: By making the clip view the payment event, we eliminate the need for subscriptions or platform-wide ads. The $0.01 price point turns social viewing into a direct revenue stream for animators, while the Hedera transaction id serves as an immutable proof of digital ownership/access. Market: TAM $18B — The global animation and VFX industry shifting toward granular digital asset distribution. | SAM $420M — Professional animators, motion designers, and indie studios seeking per-unit content monetization. | SOM $15M — Early adopters in the decentralized animation space and AI researchers sourcing licensed datasets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameDrop" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity animation repository where viewing is a transaction. Creators upload raw clips; users pay $0.01 per high-res view or metadata scrape. Every 'share' is a signed HTS transfer intent, allowing animation enthusiasts and AI video-trainers to buy authenticated frames directly from the artist's wallet. Pay-per-frame consumption replaces ad-supported hosting. Discipline: Filmmaking & Animation (clip sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making the clip view the payment event, we eliminate the need for subscriptions or platform-wide ads. The $0.01 price point turns social viewing into a direct revenue stream for animators, while the Hedera transaction id serves as an immutable proof of digital ownership/access. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameDrop" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animfeedback-nft-20-x402 Title: FrameCritique · x402 Theme: Filmmaking & Animation (film-animation) · feedback monetization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn every pixel-critique into a stream of income. Script doctors and lead animators lock frame-specific feedback behind a 0.01 USDC x402 gate. Junior artists pay per 'reveal' to unlock professional draw-overs, timing notes, and technical breakdowns. No subscriptions—just pay for the specific expertise you need to fix a sequence, settled instantly on-chain. Why Hedera: Feedback in animation is high-value but often uncompensated in casual mentorship. x402 allows for granular, frame-by-frame monetization that makes expert critiquing a scalable micro-consultancy. Market: TAM $2.4B — The global animation and VFX professional training and upskilling market. | SAM $180M — The estimated annual spend on informal animation mentorship and portfolio review services. | SOM $12M — Professional animators and industry veterans providing on-demand feedback via decentralized social layers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameCritique" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn every pixel-critique into a stream of income. Script doctors and lead animators lock frame-specific feedback behind a 0.01 USDC x402 gate. Junior artists pay per 'reveal' to unlock professional draw-overs, timing notes, and technical breakdowns. No subscriptions—just pay for the specific expertise you need to fix a sequence, settled instantly on-chain. Discipline: Filmmaking & Animation (feedback monetization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Feedback in animation is high-value but often uncompensated in casual mentorship. x402 allows for granular, frame-by-frame monetization that makes expert critiquing a scalable micro-consultancy. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameCritique" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-storyboard-mint-21-x402 Title: FrameFlow · x402 Theme: Filmmaking & Animation (film-animation) · storyboard NFT minting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn sketchpad frames into verifiable IP. Every storyboard cell is minted via a 0.01 USDC micropayment, instantly gasless for the user. Pay-per-frame allows directors to 'stream-mint' entire sequences while they draw, building a provable on-chain production timeline without the friction of bulk minting costs. Why Hedera: x402 transforms minting from a high-stakes transaction into a continuous background utility. By metering the creation process at the frame level, the app creates a granular 'proof of labor' for animators. Market: TAM $18B — Global animation and VFX production industry embracing on-chain provenance. | SAM $450M — The digital storyboarding and pre-visualization software market moving toward decentralized IP. | SOM $12M — Independent animators and web3-native production houses using Base for cost-effective asset management. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn sketchpad frames into verifiable IP. Every storyboard cell is minted via a 0.01 USDC micropayment, instantly gasless for the user. Pay-per-frame allows directors to 'stream-mint' entire sequences while they draw, building a provable on-chain production timeline without the friction of bulk minting costs. Discipline: Filmmaking & Animation (storyboard NFT minting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: x402 transforms minting from a high-stakes transaction into a continuous background utility. By metering the creation process at the frame level, the app creates a granular 'proof of labor' for animators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-anim-guild-22-x402 Title: AnimGuild · x402 Theme: Filmmaking & Animation (film-animation) · community governance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn professional animation production into a high-frequency liquid market. Every frame, character sheet draft, and script revision is gated by a 0.01 USDC unlock. Guild members pay to vote on creative pivots, while external patrons pay per 'sneak-peek' view. Producers earn instant resolution for every micro-action, replacing slow milestone payments with real-time creative-tech settlement. Why Hedera: Governance is high-friction when it requires heavy consensus; x402 turns governance into direct, low-friction 'skin in the game.' By charging per-vote and per-view, the guild filters for high-conviction creative feedback while providing an immediate revenue stream for the studio. Market: TAM $390B — Global animation and VFX industry by 2030. | SAM $8.2B — The estimated volume of the independent animation and freelance creative-tech market. | SOM $45M — Targeting high-frequency creative guilds and 'open-source' anime projects on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "AnimGuild" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn professional animation production into a high-frequency liquid market. Every frame, character sheet draft, and script revision is gated by a 0.01 USDC unlock. Guild members pay to vote on creative pivots, while external patrons pay per 'sneak-peek' view. Producers earn instant resolution for every micro-action, replacing slow milestone payments with real-time creative-tech settlement. Discipline: Filmmaking & Animation (community governance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Governance is high-friction when it requires heavy consensus; x402 turns governance into direct, low-friction 'skin in the game.' By charging per-vote and per-view, the guild filters for high-conviction creative feedback while providing an immediate revenue stream for the studio. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "AnimGuild" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-motiontrack-rewards-23-x402 Title: Kinetix · x402 Theme: Filmmaking & Animation (film-animation) · performance incentives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-bounty protocol for technical directors. Instead of 'milestone' promises, animators authorize 0.01 USDC per frame to unlock high-fidelity motion data or compute-heavy physics simulations. Producers pay per keyframe approved, and freelancers pay per asset-pack rig check. It transforms the feedback loop into a high-velocity stream of micro-settlements, ensuring every clean-up pass is paid for in real-time. Why Hedera: In animation, 'scope creep' happens in the seconds between milestones. By pricing the performance at the frame or 'rig-unlock' level, we turn creative labor into a metered utility, eliminating the friction of invoicing for minor iterations. Market: TAM $4.2B — The global animation production market moving toward decentralized, real-time asset licensing. | SAM $850M — The global 3D animation and VFX outsourcing market transitioning to per-asset micro-contracts. | SOM $12M — Base-native technical directors and indie studios using HashPack-integrated pipelines for automated daily payroll. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetix" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-bounty protocol for technical directors. Instead of 'milestone' promises, animators authorize 0.01 USDC per frame to unlock high-fidelity motion data or compute-heavy physics simulations. Producers pay per keyframe approved, and freelancers pay per asset-pack rig check. It transforms the feedback loop into a high-velocity stream of micro-settlements, ensuring every clean-up pass is paid for in real-time. Discipline: Filmmaking & Animation (performance incentives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: In animation, 'scope creep' happens in the seconds between milestones. By pricing the performance at the frame or 'rig-unlock' level, we turn creative labor into a metered utility, eliminating the friction of invoicing for minor iterations. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetix" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-clipchain-rights-24-x402 Title: RawCut · x402 Theme: Filmmaking & Animation (film-animation) · clip rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-frame usage. A headless rights-clearing layer where 2D/3D animation clips are served as metered assets. Designers sign a 3009 transfer to unlock high-res sequences or commercial usage rights instantly. No bundles, no subscriptions—just direct micropayments from the timeline to the creator's wallet for every asset call. Why Hedera: By shifting from 'management' to 'per-use settlement,' the app removes the friction of licensing negotiations. An editor's software signs a signature, 1 cent is paid, and the asset is legally cleared and delivered via x402 metadata. Market: TAM $8.4B — The global digital asset rights and stock footage market, currently gated by high-friction legacy licensing and bank-transfer overhead. | SAM $420M — Professional animators, motion designers, and indie studios transitioning to asset-based, pay-as-you-go production workflows. | SOM $12M — Base-native creators and AI-automated video generation agents requiring programmatic, instant rights clearance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RawCut" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-frame usage. A headless rights-clearing layer where 2D/3D animation clips are served as metered assets. Designers sign a 3009 transfer to unlock high-res sequences or commercial usage rights instantly. No bundles, no subscriptions—just direct micropayments from the timeline to the creator's wallet for every asset call. Discipline: Filmmaking & Animation (clip rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'management' to 'per-use settlement,' the app removes the friction of licensing negotiations. An editor's software signs a signature, 1 cent is paid, and the asset is legally cleared and delivered via x402 metadata. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RawCut" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-framechain-legacy-0-x402 Title: InkLog · x402 Theme: Filmmaking & Animation (film-animation) · storyboard provenance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered storyboard engine where every frame-sketch and revision requires a 0.01 USDC micro-settlement to anchor provenance. Instead of a single bulk mint, creators pay-per-save to build a cryptographically verifiable 'paper trail' of authorship. Directors and studios can buy 'Review Keys' to unlock specific sequences, ensuring artists are paid for every eyes-on glance at their proprietary IP. Pre-visualize with proof. Why Hedera: x402 transforms provenance from a static badge into an active audit log. By charging per-frame entry and per-sequence view, it prevents IP theft during the pitch phase and ensures animators are compensated for the 'process' rather than just the final output. Market: TAM $12.5B — The total addressable creative production and IP protection market. | SAM $820M — The global pre-visualization and animation service market migrating to fractional IP tracking. | SOM $14M — Independent storyboard artists and boutique animation houses using Base for secure creative handoffs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkLog" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered storyboard engine where every frame-sketch and revision requires a 0.01 USDC micro-settlement to anchor provenance. Instead of a single bulk mint, creators pay-per-save to build a cryptographically verifiable 'paper trail' of authorship. Directors and studios can buy 'Review Keys' to unlock specific sequences, ensuring artists are paid for every eyes-on glance at their proprietary IP. Pre-visualize with proof. Discipline: Filmmaking & Animation (storyboard provenance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: x402 transforms provenance from a static badge into an active audit log. By charging per-frame entry and per-sequence view, it prevents IP theft during the pitch phase and ensures animators are compensated for the 'process' rather than just the final output. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkLog" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animmint-vault-1-x402 Title: Kinetic · x402 Theme: Filmmaking & Animation (film-animation) · character animation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-frame character rigged motion data. Instead of licensing bulky libraries, pay 0.01 USDC to unlock specific BVH or FBX motion sequences via HTS transfer. Each micro-transaction generates an on-chain receipt, providing instant, verifiable usage rights for indie animators and AI-driven video generators requiring precise skeletal movement without the overhead of massive subscriptions. Why Hedera: By turning animation assets into metered primitives, we solve the 'all-or-nothing' licensing problem in 3D production. x402 allows for granular asset streaming where creators are paid instantly as their work is integrated into scenes. Market: TAM $30B — The global 3D animation and VFX software ecosystem. | SAM $450M — The independent animation and game asset licensing market. | SOM $12M — Micro-licensing for indie devs and AI video training workflows on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-frame character rigged motion data. Instead of licensing bulky libraries, pay 0.01 USDC to unlock specific BVH or FBX motion sequences via HTS transfer. Each micro-transaction generates an on-chain receipt, providing instant, verifiable usage rights for indie animators and AI-driven video generators requiring precise skeletal movement without the overhead of massive subscriptions. Discipline: Filmmaking & Animation (character animation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning animation assets into metered primitives, we solve the 'all-or-nothing' licensing problem in 3D production. x402 allows for granular asset streaming where creators are paid instantly as their work is integrated into scenes. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-scenestamp-auth-2-x402 Title: SceneStamp · x402 Theme: Filmmaking & Animation (film-animation) · scene composition Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: SceneStamp is a programmable cinematography engine. Directors and animators pay 0.01 USDC to 'Freeze' a spatial composition—saving precise lens data, lighting coordinates, and blocking vectors as a signed cryptographic proof. Use it to meter visual access where clients pay per frame reveal, or enable AI renderers to 'rent' human-composed scene math for 0.01 USDC per generation. Pay to lock the frame, pay to fork the vision. Why Hedera: By turning scene composition into a paid API call rather than a static NFT mint, the intellectual property of 'framing' becomes a liquid, metered resource for the animation pipeline. Market: TAM $12B — Global market for licensed cinematography and digital production workflows. | SAM $450M — The production value of indie animation and game cinematics seeking modular assets. | SOM $18M — Micropayments for metadata-rich scene templates used by decentralized GPU rendering networks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SceneStamp" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT SceneStamp is a programmable cinematography engine. Directors and animators pay 0.01 USDC to 'Freeze' a spatial composition—saving precise lens data, lighting coordinates, and blocking vectors as a signed cryptographic proof. Use it to meter visual access where clients pay per frame reveal, or enable AI renderers to 'rent' human-composed scene math for 0.01 USDC per generation. Pay to lock the frame, pay to fork the vision. Discipline: Filmmaking & Animation (scene composition). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning scene composition into a paid API call rather than a static NFT mint, the intellectual property of 'framing' becomes a liquid, metered resource for the animation pipeline. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SceneStamp" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-motionmark-ledger-3-x402 Title: MotionMark · x402 Theme: Filmmaking & Animation (film-animation) · motion design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-frame provenance for premium motion assets. Creators upload source files and render-ready loops; users pay 0.01 USDC via x402 to unlock high-res, water-mark free exports with an on-chain licensing hash generated per download. No subscriptions—just micropayments for motion royalty. Why Hedera: By shifting from speculative NFTs to utility-based micropayments, 'MotionMark' becomes a high-velocity library where high-quality motion design is metered. It solves the friction of licensing small assets (lower-thirds, transitions, loops) by making the payment a 1-click primitive in the design workflow. Market: TAM $4.5B — The global motion graphics and animation industry, inclusive of the rising creator economy. | SAM $850M — The addressable market for digital stock assets and motion templates integrated with web3 tooling. | SOM $12M — Target capture of high-frequency motion designers and editors switching to pay-per-use licensing models on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MotionMark" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-frame provenance for premium motion assets. Creators upload source files and render-ready loops; users pay 0.01 USDC via x402 to unlock high-res, water-mark free exports with an on-chain licensing hash generated per download. No subscriptions—just micropayments for motion royalty. Discipline: Filmmaking & Animation (motion design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from speculative NFTs to utility-based micropayments, 'MotionMark' becomes a high-velocity library where high-quality motion design is metered. It solves the friction of licensing small assets (lower-thirds, transitions, loops) by making the payment a 1-click primitive in the design workflow. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MotionMark" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-colorproof-chain-4-x402 Title: Chromastream · x402 Theme: Filmmaking & Animation (film-animation) · color grading Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized Look Up Table (LUT) library where cinematic grading profiles are metered by the frame or the download. Instead of buying static packs, editors stream professional grade-data directly into their timeline, paying 0.01 USDC per application via x402. Every LUT 'pull' triggers an instant micro-settlement to the colorist, turning creative profiles into autonomous, revenue-generating assets. Why Hedera: Transforms color grading from a one-time file purchase into a utility-based service. By using x402, it prevents mass piracy of LUT files; if you want the grade on a new project, you sign a 0.01 USDC event. This aligns the cost of production with the volume of use. Market: TAM $3.2B — The global professional film editing and digital content creation software market. | SAM $550M — The high-end post-production market and independent colorist workforce using DaVinci Resolve and Adobe Premiere. | SOM $12M — Early adopter colorists on Hedera who want to monetize their signature 'looks' without the friction of storefronts or high-fee marketplaces. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chromastream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized Look Up Table (LUT) library where cinematic grading profiles are metered by the frame or the download. Instead of buying static packs, editors stream professional grade-data directly into their timeline, paying 0.01 USDC per application via x402. Every LUT 'pull' triggers an instant micro-settlement to the colorist, turning creative profiles into autonomous, revenue-generating assets. Discipline: Filmmaking & Animation (color grading). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transforms color grading from a one-time file purchase into a utility-based service. By using x402, it prevents mass piracy of LUT files; if you want the grade on a new project, you sign a 0.01 USDC event. This aligns the cost of production with the volume of use. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chromastream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-vfx-provenance-5-x402 Title: PlateFlow · x402 Theme: Filmmaking & Animation (film-animation) · visual effects Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: 0.05 USDC — Attest the chain of custody for one high-fidelity VFX plate. Instead of monolithic licensing, producers pay per retrieval of metadata, mask, or render-pass via HTS transfer. Every time a VFX vendor pulls a master file or a supervisor signs off on a frame, the transaction is immutable and paid, ensuring the original artist is compensated for every touchpoint in the pipeline. Why Hedera: Traditional VFX 'provenance' is a passive log. x402 turns it into a metered access layer where the audit trail is a stream of micropayments, making the asset 'live' and revenue-generating for the artist throughout the post-production cycle. Market: TAM $9.5B — Total worldwide Visual Effects and Animation software and production services market. | SAM $400M — Global VFX outsourcing and asset management market segmenting into decentralized workflows. | SOM $15M — Independent VFX boutiques and remote post-houses adopting automated, pay-per-pass asset tracking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PlateFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT 0.05 USDC — Attest the chain of custody for one high-fidelity VFX plate. Instead of monolithic licensing, producers pay per retrieval of metadata, mask, or render-pass via HTS transfer. Every time a VFX vendor pulls a master file or a supervisor signs off on a frame, the transaction is immutable and paid, ensuring the original artist is compensated for every touchpoint in the pipeline. Discipline: Filmmaking & Animation (visual effects). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional VFX 'provenance' is a passive log. x402 turns it into a metered access layer where the audit trail is a stream of micropayments, making the asset 'live' and revenue-generating for the artist throughout the post-production cycle. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PlateFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-propmint-archive-6-x402 Title: VertexFlow · x402 Theme: Filmmaking & Animation (film-animation) · 3D prop modeling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless 3D mesh repository where animators pay 0.01 USDC per asset 'pull' or render-call. Instead of clunky licensing contracts, every import into a scene (Blender, Unreal, or Unity) triggers a micropayment to the creator via HTS transfer. Creators stream revenue in real-time as their props are used in production, while studios avoid massive upfront library fees. Why Hedera: By shifting from 'Minting' to 'Metering,' we turn static assets into active revenue streams. Using x402 allows for per-object or per-render-instance billing, which is the natural economic unit for high-volume 3D production. Market: TAM $18B — The total addressable market for digital twins, gaming assets, and metaverse interoperability. | SAM $4.2B — The global 3D animation software and asset licensing market. | SOM $120M — Indie animation studios and freelance 3D artists rotating high-frequency assets on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VertexFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless 3D mesh repository where animators pay 0.01 USDC per asset 'pull' or render-call. Instead of clunky licensing contracts, every import into a scene (Blender, Unreal, or Unity) triggers a micropayment to the creator via HTS transfer. Creators stream revenue in real-time as their props are used in production, while studios avoid massive upfront library fees. Discipline: Filmmaking & Animation (3D prop modeling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'Minting' to 'Metering,' we turn static assets into active revenue streams. Using x402 allows for per-object or per-render-instance billing, which is the natural economic unit for high-volume 3D production. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VertexFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-voicetrack-token-7-x402 Title: VocalSeal · x402 Theme: Filmmaking & Animation (film-animation) · voiceover recording Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An x402-native voiceover ledger where every playback and download triggers a 0.01 USDC royalty directly to the artist's Magic Link email sign-in. VO actors sign their audio 'prints,' and creators pay per-use to clear usage rights instantly, replacing manual licensing with per-clip micro-settlements. Why Hedera: Moving from a static NFT (one-time sale/mint) to a metered x402 model ensures the voice actor is paid for actual consumption. It turns audio provenance into a revenue-generating stream where 'listening' or 'embedding' is the primitive. Market: TAM $4.8B — Global voiceover and automated narration market. | SAM $120M — Professional VO artists and indie animation studios migrating to automated licensing. | SOM $8.5M — Early-adopter TikTok/Reel animators and AI-voice trainers requiring verifiable human training data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VocalSeal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An x402-native voiceover ledger where every playback and download triggers a 0.01 USDC royalty directly to the artist's Magic Link email sign-in. VO actors sign their audio 'prints,' and creators pay per-use to clear usage rights instantly, replacing manual licensing with per-clip micro-settlements. Discipline: Filmmaking & Animation (voiceover recording). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a static NFT (one-time sale/mint) to a metered x402 model ensures the voice actor is paid for actual consumption. It turns audio provenance into a revenue-generating stream where 'listening' or 'embedding' is the primitive. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VocalSeal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animsound-ledger-8-x402 Title: SonicSync · x402 Theme: Filmmaking & Animation (film-animation) · sound design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Foley-as-a-Service for the agentic era. Pay $0.01 USDC to instantly pull a high-fidelity, license-cleared sound effect directly into your timeline or generative video workflow. Every 'Sync' call triggers an on-chain receipt, automating royalty distribution to sound designers while providing filmmakers with a cryptographically verifiable provenance trail for festival delivery. Why Hedera: Shifts from static NFT ownership to high-velocity utility. x402 eliminates the friction of licensing desks by turning 'play/download' into a metered transaction. Market: TAM $8.9B — Global stock media and sound effects market. | SAM $1.2B — The growing market for generative video assets and automated post-production tools. | SOM $45M — Niche focus on indie animators and AI-video startups requiring instant, legally-safe sound libraries. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SonicSync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Foley-as-a-Service for the agentic era. Pay $0.01 USDC to instantly pull a high-fidelity, license-cleared sound effect directly into your timeline or generative video workflow. Every 'Sync' call triggers an on-chain receipt, automating royalty distribution to sound designers while providing filmmakers with a cryptographically verifiable provenance trail for festival delivery. Discipline: Filmmaking & Animation (sound design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from static NFT ownership to high-velocity utility. x402 eliminates the friction of licensing desks by turning 'play/download' into a metered transaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SonicSync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-storyboard-chain-9-x402 Title: Storyboard · x402 Theme: Filmmaking & Animation (film-animation) · visual storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Per panel unlock. Storyboard Chain turns every frame of a production into a monetized asset. Instead of bulky production fees, directors and studios stream USDC to artists for every unique board viewed, every version iterated, and every frame approved. It replaces legal contracts with HTS transfer signatures, allowing AI story-finishers and human lead animators to get paid instantly as the vision evolves. No subscriptions, just a metered flow of creativity from sketch to final render. Why Hedera: Moving from 'rights tracking' (passive) to 'per-view/per-iteration' (active) creates a micro-economy for pre-production. It rewards high-volume visionaries and enables granular billing for large animation houses. Market: TAM $18B — The global pre-production and visualization market for film, gaming, and advertising. | SAM $420M — Decentralized animation studios and indie producers utilizing pay-as-you-go visual development tools. | SOM $15M — Early-stage Base-native content creators and AI-integrated storyboarding pipelines. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Storyboard" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Per panel unlock. Storyboard Chain turns every frame of a production into a monetized asset. Instead of bulky production fees, directors and studios stream USDC to artists for every unique board viewed, every version iterated, and every frame approved. It replaces legal contracts with HTS transfer signatures, allowing AI story-finishers and human lead animators to get paid instantly as the vision evolves. No subscriptions, just a metered flow of creativity from sketch to final render. Discipline: Filmmaking & Animation (visual storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'rights tracking' (passive) to 'per-view/per-iteration' (active) creates a micro-economy for pre-production. It rewards high-volume visionaries and enables granular billing for large animation houses. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Storyboard" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animloop-provenance-10-x402 Title: FrameRoot · x402 Theme: Filmmaking & Animation (film-animation) · animation loops Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-frame-inject. A headless animation library where every loop import, metadata query, or high-res export is a micro-transaction. Instead of 'buying' a loop, developers and VJs stream payments directly to the animator's wallet via HTS transfer. Ideal for generative art engines that need to source human-made motion assets on-demand without hefty upfront licensing costs. Why Hedera: Shifts animation from 'asset ownership' to 'usage utility.' By pricing at the call level, it allows AI-driven video editors to pull frames programmatically, ensuring the creator is paid for every single render cycle. Market: TAM $3.8B — The global 2D/3D animation software and digital asset marketplace economy. | SAM $420M — Professional motion designers and real-time VJ software users transitioning to modular asset workflows. | SOM $12M — Early adopters in the generative AI video space requiring authenticated 'human-in-the-loop' motion data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameRoot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-frame-inject. A headless animation library where every loop import, metadata query, or high-res export is a micro-transaction. Instead of 'buying' a loop, developers and VJs stream payments directly to the animator's wallet via HTS transfer. Ideal for generative art engines that need to source human-made motion assets on-demand without hefty upfront licensing costs. Discipline: Filmmaking & Animation (animation loops). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts animation from 'asset ownership' to 'usage utility.' By pricing at the call level, it allows AI-driven video editors to pull frames programmatically, ensuring the creator is paid for every single render cycle. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameRoot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-cuttoken-verify-11-x402 Title: FinalCut Pay · x402 Theme: Filmmaking & Animation (film-animation) · film editing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-frame rendering and licensing engine for film editors. Instead of flat-fee distribution, editors lock high-resolution master exports behind x402 gates. Distributors or colorists pay 0.05 USDC to unlock specific scene sequences or download raw LUT metadata. Every 'render' or 'preview' call creates an on-chain receipt, turning the edit timeline into a metered revenue stream. Why Hedera: Moving from one-time NFT minting to a request-based payment model ensures editors are paid for every review cycle and high-res pull, preventing the 'unpaid revision' trap and automating licensing via micropayments. Market: TAM $12B — The global digital video editing and distribution software market, encompassing professional cinema and creator-economy content. | SAM $850M — The independent film post-production market moving toward cloud-based collaborative editing and decentralized asset management. | SOM $45M — Freelance editors and boutique production houses on Hedera using HashPack-enabled workflows for client handoffs and asset verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FinalCut Pay" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-frame rendering and licensing engine for film editors. Instead of flat-fee distribution, editors lock high-resolution master exports behind x402 gates. Distributors or colorists pay 0.05 USDC to unlock specific scene sequences or download raw LUT metadata. Every 'render' or 'preview' call creates an on-chain receipt, turning the edit timeline into a metered revenue stream. Discipline: Filmmaking & Animation (film editing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from one-time NFT minting to a request-based payment model ensures editors are paid for every review cycle and high-res pull, preventing the 'unpaid revision' trap and automating licensing via micropayments. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FinalCut Pay" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-storyboardchain-script-12-x402 Title: DraftSync · x402 Theme: Filmmaking & Animation (film-animation) · script development Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless scriptwriting engine where every 'Scene Generate' or 'Dialogue Polish' request costs $0.01 USDC. Writers pay to invoke AI-assisted scene building, while collaborators pay to unlock specific drafts. By turning version control into a stream of micro-transactions, each revision is cryptographically timestamped and provenance-sealed on Hedera, ensuring the paper trail for WGA credits is immutable and paid for by the millisecond. Why Hedera: Script development is iterative and high-volume. Replacing a flat subscription with x402 allows writers to pay only for the compute they use, while producers pay tiny fees to access 'locked' read-only versions, turning script security into a metered utility. Market: TAM $4.2B — The total creator economy segment for film/TV development and intellectual property management. | SAM $850M — The global screenwriting software and pre-production tools market. | SOM $12M — Independent screenwriters and indie animation studios shifting to pay-as-you-go AI development tools on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DraftSync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless scriptwriting engine where every 'Scene Generate' or 'Dialogue Polish' request costs $0.01 USDC. Writers pay to invoke AI-assisted scene building, while collaborators pay to unlock specific drafts. By turning version control into a stream of micro-transactions, each revision is cryptographically timestamped and provenance-sealed on Hedera, ensuring the paper trail for WGA credits is immutable and paid for by the millisecond. Discipline: Filmmaking & Animation (script development). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Script development is iterative and high-volume. Replacing a flat subscription with x402 allows writers to pay only for the compute they use, while producers pay tiny fees to access 'locked' read-only versions, turning script security into a metered utility. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DraftSync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-moodboard-token-13-x402 Title: VisionGate · x402 Theme: Filmmaking & Animation (film-animation) · concept art Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: MoodBoard is a direct-settlement canvas for cinematic vision. Instead of minting assets for high fees, directors and production houses pay 0.01 USDC to 'Unlock' or 'Stamp' a concept art board. This fee creates a cryptographically signed provenance record of the creative direction, ensuring that an artist's signature style or a director's aesthetic is metered and protected before the first frame is ever rendered. Every time a board is referenced by the VFX pipeline or AI-generation models, a micropayment is triggered to the originator. Why Hedera: By replacing bulky NFT mints with x402 micropayments, we turn a static image into a metered piece of intellectual property. This allows for high-velocity IP protection during the iterative 'Greenlight' phase of filmmaking where hundreds of boards are created but rarely archived. Market: TAM $4.2B — Global entertainment pre-production and concept art licensing market. | SAM $850M — Independent animation studios and AAA concept art departments utilizing Base. | SOM $12M — Early-stage digital concept artists and pre-vis supervisors in the indie film pivot to on-chain workflows. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VisionGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT MoodBoard is a direct-settlement canvas for cinematic vision. Instead of minting assets for high fees, directors and production houses pay 0.01 USDC to 'Unlock' or 'Stamp' a concept art board. This fee creates a cryptographically signed provenance record of the creative direction, ensuring that an artist's signature style or a director's aesthetic is metered and protected before the first frame is ever rendered. Every time a board is referenced by the VFX pipeline or AI-generation models, a micropayment is triggered to the originator. Discipline: Filmmaking & Animation (concept art). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing bulky NFT mints with x402 micropayments, we turn a static image into a metered piece of intellectual property. This allows for high-velocity IP protection during the iterative 'Greenlight' phase of filmmaking where hundreds of boards are created but rarely archived. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VisionGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animframe-tag-14-x402 Title: ONION SKINN · x402 Theme: Filmmaking & Animation (film-animation) · frame-by-frame animation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-frame provenance for animators. Instead of bulk minting, creators gate individual hi-res cels behind micro-transactions. Animators stream work-in-progress frames to collaborators or collectors; 0.01 USDC unlocks the raw SVG/vector data for usage or peer-review. Payment creates an immutable, timestamped 'proof-of-effort' on Hedera for every single frame produced. Why Hedera: Shifts the model from speculative NFT drops to a metered 'Proof of Labor' model where the cost to view/access the source file is integrated into the workflow, automated by HTS transfer. Market: TAM $42B — The global animation, VFX, and video game production industry adopting micro-licensed assets. | SAM $850M — The creative professional market for 2D animation, storyboarding, and frame-by-frame production tools. | SOM $12M — Independent animators and boutique studios using Base/HashPack for instant, global per-frame licensing via x402. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ONION SKINN" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-frame provenance for animators. Instead of bulk minting, creators gate individual hi-res cels behind micro-transactions. Animators stream work-in-progress frames to collaborators or collectors; 0.01 USDC unlocks the raw SVG/vector data for usage or peer-review. Payment creates an immutable, timestamped 'proof-of-effort' on Hedera for every single frame produced. Discipline: Filmmaking & Animation (frame-by-frame animation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from speculative NFT drops to a metered 'Proof of Labor' model where the cost to view/access the source file is integrated into the workflow, automated by HTS transfer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ONION SKINN" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-storyboardclip-token-15-x402 Title: GhostFrame · x402 Theme: Filmmaking & Animation (film-animation) · animatic creation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Storyboard and animate frame-by-frame with zero subscription overhead. Pay 0.01 USDC per frame render or AI-assisted pose generation. Each stroke and adjustment is committed via x402, automatically securing your intellectual property rights on-chain as you create. Turn your workflow into a micro-metered production house where the cost of a feature-length animatic is less than a coffee, but every frame is cryptographically proven. Why Hedera: By shifting from 'NFT minting' to 'pay-per-render' micropayments, we remove the friction of high gas fees and the psychological barrier of 'investing' in an NFT. The payment acts as the timestamp and proof-of-work, making the act of creation the act of securing ownership. Market: TAM $3.2B — Global 2D/3D animation software and pre-production market. | SAM $450M — Independent animators and pre-visualization houses adopting micro-SaaS over monthly subscriptions. | SOM $12M — Web3-native storyboard artists and indie animation studios on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GhostFrame" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Storyboard and animate frame-by-frame with zero subscription overhead. Pay 0.01 USDC per frame render or AI-assisted pose generation. Each stroke and adjustment is committed via x402, automatically securing your intellectual property rights on-chain as you create. Turn your workflow into a micro-metered production house where the cost of a feature-length animatic is less than a coffee, but every frame is cryptographically proven. Discipline: Filmmaking & Animation (animatic creation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'NFT minting' to 'pay-per-render' micropayments, we remove the friction of high gas fees and the psychological barrier of 'investing' in an NFT. The payment acts as the timestamp and proof-of-work, making the act of creation the act of securing ownership. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GhostFrame" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-charactermint-badge-16-x402 Title: InkFlow · x402 Theme: Filmmaking & Animation (film-animation) · character design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn every character turn-around into a micro-licensed asset. Instead of static NFT badges, use $0.01 HTS transfer signatures to unlock high-res source files, reference sheets, or commercial rig usage rights. Producers and animators pay-per-view or pay-per-download, ensuring designers get instant, streaming attribution revenue as the production scales. Payment is the proof of license. Why Hedera: Moving from static minting to x402-native micro-licensing creates a frictionless 'toll-gate' for production assets. It replaces clunky legal paperwork with instant-settlement on Hedera, allowing character designers to monetize the internal review process of a film production. Market: TAM $15B — The global animation and VFX outsourcing market transitioning to on-chain asset management. | SAM $95M — Licensed digital assets for independent animation studios and indie game devs. | SOM $2.4M — Freelance character designers on Hedera using per-view licensing for portfolio protection. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn every character turn-around into a micro-licensed asset. Instead of static NFT badges, use $0.01 HTS transfer signatures to unlock high-res source files, reference sheets, or commercial rig usage rights. Producers and animators pay-per-view or pay-per-download, ensuring designers get instant, streaming attribution revenue as the production scales. Payment is the proof of license. Discipline: Filmmaking & Animation (character design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from static minting to x402-native micro-licensing creates a frictionless 'toll-gate' for production assets. It replaces clunky legal paperwork with instant-settlement on Hedera, allowing character designers to monetize the internal review process of a film production. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animvoice-ledger-17-x402 Title: VoxFlow · x402 Theme: Filmmaking & Animation (film-animation) · voice acting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: High-fidelity voice performance delivery on-demand. Replace complex licensing with a raw micropayment primitive: $0.05 USDC to unlock a specific vocal stem, emotion-take, or character-set for your timeline. The transaction hash serves as the verifiable license and 'right to use' proof in the animation metadata, enabling creators to pay actors per-asset rather than per-session. Why Hedera: Moving from chunky NFT royalties to x402 allows for granular 'session-less' voice acting. Animators can pull individual lines or sound bites from a library, with payment settling instantly to the actor's Magic Link email sign-in via HTS transfer. The facilitator handles the distribution, turning every 'save' or 'export' in the DAW/Animation software into a direct revenue event for the talent. Market: TAM $2.8B — The global voice-over and character licensing market transitioning to automated, programmable rights. | SAM $420M — Professional indie animators, mobile game developers, and YouTube creators using pay-as-you-go assets. | SOM $12M — Base-native animation studios and AI-avatar creators requiring authenticated human voice stems. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VoxFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT High-fidelity voice performance delivery on-demand. Replace complex licensing with a raw micropayment primitive: $0.05 USDC to unlock a specific vocal stem, emotion-take, or character-set for your timeline. The transaction hash serves as the verifiable license and 'right to use' proof in the animation metadata, enabling creators to pay actors per-asset rather than per-session. Discipline: Filmmaking & Animation (voice acting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from chunky NFT royalties to x402 allows for granular 'session-less' voice acting. Animators can pull individual lines or sound bites from a library, with payment settling instantly to the actor's Magic Link email sign-in via HTS transfer. The facilitator handles the distribution, turning every 'save' or 'export' in the DAW/Animation software into a direct revenue event for the talent. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VoxFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-propchain-license-18-x402 Title: SetPiece · x402 Theme: Filmmaking & Animation (film-animation) · virtual prop rental Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time 3D asset server for Unreal/Unity developers. Pay 0.05 USDC to instantiate a high-fidelity virtual prop into your scene for a 24-hour production window. Avoid heavy upfront licensing for background assets; pay only for the props that make the final cut. Flow: Signed HTS transfer request unlocks the high-poly USDZ download link and generates a Base settlement hash for the artist. Why Hedera: Traditional licensing is rigid and expensive. x402 enables 'streaming' physical-to-virtual assets, turning prop managers into high-frequency micro-vendors. By shifting from per-seat licenses to per-spawn micropayments, indie filmmakers can access Hollywood-grade libraries on a shoestring budget. Market: TAM $4.2B — The global virtual production and VFX asset licensing market. | SAM $850M — The shared asset library market for indie game devs and virtual production houses using Base/USDC. | SOM $12M — Revenue from 'Hero' prop rentals for decentralized short-film competitions and AI-generated cinematic clips. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SetPiece" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time 3D asset server for Unreal/Unity developers. Pay 0.05 USDC to instantiate a high-fidelity virtual prop into your scene for a 24-hour production window. Avoid heavy upfront licensing for background assets; pay only for the props that make the final cut. Flow: Signed HTS transfer request unlocks the high-poly USDZ download link and generates a Base settlement hash for the artist. Discipline: Filmmaking & Animation (virtual prop rental). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is rigid and expensive. x402 enables 'streaming' physical-to-virtual assets, turning prop managers into high-frequency micro-vendors. By shifting from per-seat licenses to per-spawn micropayments, indie filmmakers can access Hollywood-grade libraries on a shoestring budget. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SetPiece" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animtext-token-19-x402 Title: GLYPH · x402 Theme: Filmmaking & Animation (film-animation) · animated typography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A motion-design primitive where every frame of typography is a metered asset. Designers host high-end animated font libraries (Lottie/Rive) behind x402 gates. Video editors and AI video generators pay 0.01 USDC per glyph render or motion-path export. No subscriptions; you pay exactly for the letters you animate. Why Hedera: Moving from 'NFT ownership' to 'pay-per-render' aligns with the high-frequency needs of modern content creators. It turns static fonts into a real-time revenue stream for typographers. Market: TAM $2.4B — The global digital typography and animation software market shifting to API-based consumption. | SAM $120M — The motion graphics and dynamic branding sector utilizing pay-as-you-go assets. | SOM $850K — Individual motion designers and automated ad-gen bots on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GLYPH" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A motion-design primitive where every frame of typography is a metered asset. Designers host high-end animated font libraries (Lottie/Rive) behind x402 gates. Video editors and AI video generators pay 0.01 USDC per glyph render or motion-path export. No subscriptions; you pay exactly for the letters you animate. Discipline: Filmmaking & Animation (animated typography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'NFT ownership' to 'pay-per-render' aligns with the high-frequency needs of modern content creators. It turns static fonts into a real-time revenue stream for typographers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GLYPH" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-sceneaccess-pass-20-x402 Title: Backlot · x402 Theme: Filmmaking & Animation (film-animation) · virtual production Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity staging ground for Unreal Engine filmmakers. No subscriptions or massive asset libraries to buy—pay 0.01 USDC per asset pull or environment bake. Whether it's a volumetric scan or a lighting rig, the scene data streams to your local engine the moment the signature clears. Perfectly tuned for indie virtual production houses and AI-driven storyboarders. Why Hedera: Moves virtual production from a 'ownership' model (NFTs) to a 'consumption' model. By metering the access to high-weight scene data (USD/FBX files), creators only pay for the specific lighting setups or environments they actually use in a shoot. Market: TAM $22B — The global animation and VFX production industry shifting toward real-time workflows. | SAM $1.4B — The virtual production and real-time rendering software market for indie studios. | SOM $12M — Micro-licensing fees for individual assets and scene presets within the indie film community. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Backlot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity staging ground for Unreal Engine filmmakers. No subscriptions or massive asset libraries to buy—pay 0.01 USDC per asset pull or environment bake. Whether it's a volumetric scan or a lighting rig, the scene data streams to your local engine the moment the signature clears. Perfectly tuned for indie virtual production houses and AI-driven storyboarders. Discipline: Filmmaking & Animation (virtual production). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves virtual production from a 'ownership' model (NFTs) to a 'consumption' model. By metering the access to high-weight scene data (USD/FBX files), creators only pay for the specific lighting setups or environments they actually use in a shoot. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Backlot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animsketch-token-21-x402 Title: FLIPBOOK · x402 Theme: Filmmaking & Animation (film-animation) · animator sketchbooks Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity digital sketchbook for animators where every page turn or 'onion-skin' layer toggle triggers a 0.01 USDC streaming payment to the artist. Instead of static NFTs, collectors pay to 'flip' through the creative process in real-time. Each stroke sequence is gated by an HTS transfer signature, allowing fans to micro-fund the labor-intensive act of hand-drawn animation as it happens. Payment settles the frame to the ledger, turning 'process' into a metered asset. Why Hedera: Traditional NFTs gate the final product; this gates the 'motion journey.' By making each frame or layer a micro-transaction, it creates a sustainable 'Pay-to-Peep' model for professional animators, turning their workflow into a live, revenue-generating stream. Market: TAM $2.8B — The global animation production and creative asset licensing market transitioning to direct-to-fan micro-monetization. | SAM $450M — The digital art subscription and Patreon-style 'behind the scenes' market for independent creators. | SOM $12M — Early adopters among 2D character animators and storyboard artists on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FLIPBOOK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity digital sketchbook for animators where every page turn or 'onion-skin' layer toggle triggers a 0.01 USDC streaming payment to the artist. Instead of static NFTs, collectors pay to 'flip' through the creative process in real-time. Each stroke sequence is gated by an HTS transfer signature, allowing fans to micro-fund the labor-intensive act of hand-drawn animation as it happens. Payment settles the frame to the ledger, turning 'process' into a metered asset. Discipline: Filmmaking & Animation (animator sketchbooks). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFTs gate the final product; this gates the 'motion journey.' By making each frame or layer a micro-transaction, it creates a sustainable 'Pay-to-Peep' model for professional animators, turning their workflow into a live, revenue-generating stream. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FLIPBOOK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-storyboardsync-ledger-22-x402 Title: DraftDraft · x402 Theme: Filmmaking & Animation (film-animation) · collaborative storyboarding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A collaborative canvas where every stroke, frame-add, or version-branch is a paid micro-transaction. Pay $0.01 per frame update to commit your edit to the global sequence. The protocol settles real-time royalties back to original artists whenever their frames are 'inherited' or branched into a final production director's cut. No more disputed credits; the ledger is the source of truth, paid for by the contributors. Why Hedera: By replacing free 'autosave' with x402 commits, the app creates a high-integrity audit trail for IP. This eliminates 'free-riding' in collaborative sessions and ensures that heavy contributors are compensated through the downstream inheritance of their paid commits. Market: TAM $2.4B — The global pre-production and animation software market shifting toward granular, fractionalized IP rights. | SAM $180M — Independent animation studios and remote creative agencies adopting 'proof-of-contribution' workflows. | SOM $4.2M — Early-adopter storyboard artists and web3 animation collectives on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DraftDraft" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A collaborative canvas where every stroke, frame-add, or version-branch is a paid micro-transaction. Pay $0.01 per frame update to commit your edit to the global sequence. The protocol settles real-time royalties back to original artists whenever their frames are 'inherited' or branched into a final production director's cut. No more disputed credits; the ledger is the source of truth, paid for by the contributors. Discipline: Filmmaking & Animation (collaborative storyboarding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing free 'autosave' with x402 commits, the app creates a high-integrity audit trail for IP. This eliminates 'free-riding' in collaborative sessions and ensures that heavy contributors are compensated through the downstream inheritance of their paid commits. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DraftDraft" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animpitch-token-23-x402 Title: Storyboard Ledger · x402 Theme: Filmmaking & Animation (film-animation) · project pitching Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A 'Proof-of-Interest' marketplace for animation pilots. Studios and producers pay $0.05 USDC to decrypt and view a high-fidelity pitch deck or storyboard. The x402 transaction creates an on-chain receipt of viewing, establishing a legal paper trail of access that protects creator IP while filtering for serious inquiries. Every 'Slide Next' is a micro-transaction, ensuring creators are paid for the time spent reviewing their vision. Why Hedera: Shifts from static NFT ownership to an active 'Pay-to-View' model. It solves the 'Hollywood Inbox' problem by requiring a micropayment to access intellectual property, turning every pitch into a revenue-generating event and a verifiable record of disclosure. Market: TAM $1.8B — The global entertainment IP licensing and pre-production finance market. | SAM $340M — The independent animation & visual effects licensing market, focusing on digital distribution and pilot sales. | SOM $12M — Series A and seed-stage animation projects seeking secure, paid-entry distribution to global streaming buyers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Storyboard Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A 'Proof-of-Interest' marketplace for animation pilots. Studios and producers pay $0.05 USDC to decrypt and view a high-fidelity pitch deck or storyboard. The x402 transaction creates an on-chain receipt of viewing, establishing a legal paper trail of access that protects creator IP while filtering for serious inquiries. Every 'Slide Next' is a micro-transaction, ensuring creators are paid for the time spent reviewing their vision. Discipline: Filmmaking & Animation (project pitching). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from static NFT ownership to an active 'Pay-to-View' model. It solves the 'Hollywood Inbox' problem by requiring a micropayment to access intellectual property, turning every pitch into a revenue-generating event and a verifiable record of disclosure. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Storyboard Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA film-animation-animcycle-provenance-24-x402 Title: Kinetic · x402 Theme: Filmmaking & Animation (film-animation) · loop cycles Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless animation library where developers and creators pay-per-frame to pull motion data into their engines. Instead of licensing bulky files, your engine calls the 'Loop' API to fetch the next coordinate or SVG path in a cycle. Each call pays the animator 0.01 USDC instantly, making walk cycles a metered utility for indie games and web-based AR. Why Hedera: Traditional licensing is high-friction; x402 enables 'streaming' animation data. It turns loop cycles into a granular service—paying for the exact number of frames rendered in a scene rather than a flat, unused license fee. Market: TAM $26B — The global 2D/3D animation software and digital asset marketplace. | SAM $850M — The micro-licensing market for indie game assets and web-based motion graphics. | SOM $12M — Transaction volume from headless animation calls within the Base/Farcaster developer ecosystem. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless animation library where developers and creators pay-per-frame to pull motion data into their engines. Instead of licensing bulky files, your engine calls the 'Loop' API to fetch the next coordinate or SVG path in a cycle. Each call pays the animator 0.01 USDC instantly, making walk cycles a metered utility for indie games and web-based AR. Discipline: Filmmaking & Animation (loop cycles). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is high-friction; x402 enables 'streaming' animation data. It turns loop cycles into a granular service—paying for the exact number of frames rendered in a scene rather than a flat, unused license fee. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ============================================================================== THEME · Game Design & Interactive Media game designers, interactive artists, XR creators ============================================================================== ------------------------------------------------------------------------------ IDEA games-onchain-quest-ledger-0-x402 Title: Milestone · x402 Theme: Game Design & Interactive Media (games) · quest tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn every quest step into a verifiable micro-transaction. Players pay $0.01 USDC to sign 'Proof-of-Completion' for individual milestones, instantly updating a global leaderboard. Developers monetize gameplay depth through high-frequency, low-friction state updates rather than binary loot boxes. Fractionalize game progress: pay-per-checkpoint, pay-per-achievement, and pay-to-audit. Why Hedera: By shifting quest tracking from a free database entry to an x402-metered event, 'Quest Log' transforms game progression into a real-time revenue stream. It eliminates sybil-farming of rewards by requiring a $0.01 economic stake for every milestone recorded, ensuring only committed players populate the chain. Market: TAM $180B — The global gaming market transitioning toward transparent, asset-backed player identities and interoperable metaverses. | SAM $450M — Onchain gaming protocols, indie RPG developers, and game-centric DAO ecosystems. | SOM $12M — Indie developers on Hedera seeking a low-overhead alternative to complex gas-relayer infrastructure. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Milestone" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn every quest step into a verifiable micro-transaction. Players pay $0.01 USDC to sign 'Proof-of-Completion' for individual milestones, instantly updating a global leaderboard. Developers monetize gameplay depth through high-frequency, low-friction state updates rather than binary loot boxes. Fractionalize game progress: pay-per-checkpoint, pay-per-achievement, and pay-to-audit. Discipline: Game Design & Interactive Media (quest tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting quest tracking from a free database entry to an x402-metered event, 'Quest Log' transforms game progression into a real-time revenue stream. It eliminates sybil-farming of rewards by requiring a $0.01 economic stake for every milestone recorded, ensuring only committed players populate the chain. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Milestone" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-tokenized-loot-drops-1-x402 Title: LootCrack · x402 Theme: Game Design & Interactive Media (games) · item distribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency loot-cycling engine where players pay 0.01 USDC to 'crack' regional drop-tables. Instead of subscription-based RNG, every item acquisition is a discrete micropayment transaction. Use x402 to meter high-value item reveals, ensuring that rare 'Legendary' pulls generate immediate protocol revenue while providing players with a cryptographic receipt of authenticity on Hedera. Why Hedera: By moving loot interaction from an 'all-you-can-eat' model to a per-drop micropayment, the developer captures value on every single player engagement. x402 eliminates the friction of gas-fees-per-claim by bundling the logic into the transaction auth, making 'pay-to-open' a seamless game mechanic. Market: TAM $4.2B — Global virtual goods and randomized loot container market shifting toward transparent, verifiable drop rates. | SAM $950M — The projected market for 'gacha' and randomized digital secondary markets within EVM-compatible gaming ecosystems. | SOM $12M — Initial capture from indie RPGs and on-chain survival games migrating to Hedera testnet for low-cost asset distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LootCrack" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency loot-cycling engine where players pay 0.01 USDC to 'crack' regional drop-tables. Instead of subscription-based RNG, every item acquisition is a discrete micropayment transaction. Use x402 to meter high-value item reveals, ensuring that rare 'Legendary' pulls generate immediate protocol revenue while providing players with a cryptographic receipt of authenticity on Hedera. Discipline: Game Design & Interactive Media (item distribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving loot interaction from an 'all-you-can-eat' model to a per-drop micropayment, the developer captures value on every single player engagement. x402 eliminates the friction of gas-fees-per-claim by bundling the logic into the transaction auth, making 'pay-to-open' a seamless game mechanic. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LootCrack" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-player-reputation-ledger-2-x402 Title: Proof of Honor · x402 Theme: Game Design & Interactive Media (games) · community trust Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-vouch. A high-integrity matchmaking layer where players pay to submit peer reviews and developers pay to query the 'Karma' API. By attaching a micro-cost to reputation, you eliminate sybil attacks and bot-spam, ensuring that only skin-in-the-game sentiment defines a player's standing. No free reports, no fake fluff. Why Hedera: Free reputation systems are gamed by bots and 'revenge reporting.' x402 turns the reputation ledger into a high-signal economic filter. The micropayment acts as a proof-of-intent, making 'Community Trust' a tangible asset that costs real USDC to build or query. Market: TAM $2.8B — The global online gaming player-behavior and anti-cheat software market transitioning to decentralized identity. | SAM $120M — The competitive eSports and MMO market requiring anti-toxicity integration and verified matchmaking credentials. | SOM $4.5M — Initial rollout for indie competitive leagues and DAO-gated gaming guilds on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proof of Honor" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-vouch. A high-integrity matchmaking layer where players pay to submit peer reviews and developers pay to query the 'Karma' API. By attaching a micro-cost to reputation, you eliminate sybil attacks and bot-spam, ensuring that only skin-in-the-game sentiment defines a player's standing. No free reports, no fake fluff. Discipline: Game Design & Interactive Media (community trust). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Free reputation systems are gamed by bots and 'revenge reporting.' x402 turns the reputation ledger into a high-signal economic filter. The micropayment acts as a proof-of-intent, making 'Community Trust' a tangible asset that costs real USDC to build or query. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proof of Honor" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-onchain-puzzle-locks-3-x402 Title: CIPHERPAY · x402 Theme: Game Design & Interactive Media (games) · interactive puzzles Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A modular puzzle engine where every 'Check Solution' action is a $0.01 USDC micro-transaction. Players pay per attempt to validate complex logic gates or cryptographic riddles against a Hedera testnet facilitator. Game designers receive instant settlement for their content loops, turning difficulty curves into direct revenue streams. No subscriptions—just pay to solve. Why Hedera: By turning the 'Attempt' button into a paid HTS transfer call, the app eliminates bot-spamming of brute-force solutions and creates a high-stakes competitive environment where logic efficiency has a literal monetary value. Market: TAM $18B — Global interactive media and puzzle game industry pivoting toward pay-per-play and digital asset gating. | SAM $450M — The casual and web3 gaming market utilizing in-app micro-purchases and play-to-unlock mechanics. | SOM $12M — Independent puzzle designers and escape room creators launching interactive onchain content on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CIPHERPAY" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A modular puzzle engine where every 'Check Solution' action is a $0.01 USDC micro-transaction. Players pay per attempt to validate complex logic gates or cryptographic riddles against a Hedera testnet facilitator. Game designers receive instant settlement for their content loops, turning difficulty curves into direct revenue streams. No subscriptions—just pay to solve. Discipline: Game Design & Interactive Media (interactive puzzles). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the 'Attempt' button into a paid HTS transfer call, the app eliminates bot-spamming of brute-force solutions and creates a high-stakes competitive environment where logic efficiency has a literal monetary value. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CIPHERPAY" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-immutable-scoreboards-4-x402 Title: GLHF · x402 Theme: Game Design & Interactive Media (games) · competitive scoring Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-to-post high scores. A hyper-competitive, global gaming leaderboard where every entry requires a micro-signed transaction. Players pay 0.01 USDC to submit a score, creating a financial barrier against sybil-bot spam and a real-value stakes environment for speedrunners and competitive gamers. Why Hedera: Moving scoreboards from 'free storage' to 'pay-per-entry' transforms a vanity metric into a high-integrity proof of skill. x402 handles the 'anti-cheat' via the cost of entry; botting a leaderboard becomes financially unsustainable, while the facilitator ensures every world record is backed by an on-chain receipt. Market: TAM $1.8B — the global competitive gaming market and cheat-detection industry expanding into verifiable play-to-earn structures. | SAM $42M — competitive indie gaming circles, speedrunning communities, and high-stakes skill-based mobile gaming. | SOM $450K — initial integration with three Base-native arcade games and casual competitive mobile apps. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GLHF" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-to-post high scores. A hyper-competitive, global gaming leaderboard where every entry requires a micro-signed transaction. Players pay 0.01 USDC to submit a score, creating a financial barrier against sybil-bot spam and a real-value stakes environment for speedrunners and competitive gamers. Discipline: Game Design & Interactive Media (competitive scoring). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving scoreboards from 'free storage' to 'pay-per-entry' transforms a vanity metric into a high-integrity proof of skill. x402 handles the 'anti-cheat' via the cost of entry; botting a leaderboard becomes financially unsustainable, while the facilitator ensures every world record is backed by an on-chain receipt. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GLHF" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-collectible-avatars-5-x402 Title: Kromos · x402 Theme: Game Design & Interactive Media (games) · character customization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A stateless character-smith where every aesthetic choice—from skin shader to gear mesh—is a 0.01 USDC unlock triggered via signature. Instead of buying a whole NFT, players 'stream' their identity by paying for the specific frame-data and rig-assets they use per session. Developers earn real-time royalties every time a player equips their designed layer, turning character customization into a high-frequency micro-transaction loop. Why Hedera: Bypasses the 'lumpy' cost of minting full NFTs by decoupling the asset from the token. Using x402 allows for granular 'pay-per-equip' or 'pay-per-render' models, making high-quality community-made assets accessible for pennies while ensuring creators are paid instantly for every usage instance. Market: TAM $75B — Global market for digital fashion and virtual world avatar customization. | SAM $1.2B — In-game skin and cosmetic micro-transaction volume on Layer 2 networks. | SOM $15M — Independant game devs on Hedera integrating modular, pay-per-use character libraries. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kromos" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A stateless character-smith where every aesthetic choice—from skin shader to gear mesh—is a 0.01 USDC unlock triggered via signature. Instead of buying a whole NFT, players 'stream' their identity by paying for the specific frame-data and rig-assets they use per session. Developers earn real-time royalties every time a player equips their designed layer, turning character customization into a high-frequency micro-transaction loop. Discipline: Game Design & Interactive Media (character customization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Bypasses the 'lumpy' cost of minting full NFTs by decoupling the asset from the token. Using x402 allows for granular 'pay-per-equip' or 'pay-per-render' models, making high-quality community-made assets accessible for pennies while ensuring creators are paid instantly for every usage instance. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kromos" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-onchain-narrative-branches-6-x402 Title: Ghostwriter · x402 Theme: Game Design & Interactive Media (games) · storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Commit your choice to the global canon. Every Narrative Branch requires a micropayment to fork the storyline, preventing spam and placing real economic weight on character decisions. Writers earn USDC every time a player explores their specific story path, turning interactive fiction into a metered, self-sustaining multiplayer labyrinth. Why Hedera: By replacing free 'voting' with pay-per-choice, the narrative gains 'Skin in the Game.' The x402 primitive acts as a quality filter and a direct royalty stream for authors of popular branches. Market: TAM $18B — The global gaming narrative and visual novel market transitioning to creator-economy models. | SAM $850M — The interactive fiction and 'choose your own adventure' digital market. | SOM $12M — Onchain RPG players and DAO-led collaborative storytelling communities. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Ghostwriter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Commit your choice to the global canon. Every Narrative Branch requires a micropayment to fork the storyline, preventing spam and placing real economic weight on character decisions. Writers earn USDC every time a player explores their specific story path, turning interactive fiction into a metered, self-sustaining multiplayer labyrinth. Discipline: Game Design & Interactive Media (storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing free 'voting' with pay-per-choice, the narrative gains 'Skin in the Game.' The x402 primitive acts as a quality filter and a direct royalty stream for authors of popular branches. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Ghostwriter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-decentralized-game-voting-7-x402 Title: Veto · x402 Theme: Game Design & Interactive Media (games) · community governance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Transform game balance into a liquid market. Every vote, suggestion, or nerf-request costs 0.01 USDC via x402, eliminating bot spam and ensuring only skin-in-the-game players steer the meta. Developers receive a real-time revenue stream for community management while players receive a Base transaction hash as their receipt of influence. Why Hedera: By moving away from 'one-person-one-vote' (vulnerable to Sybil attacks) to 'pay-per-vote' (x402), the protocol filters for high-conviction players and provides a sustainable monetization model for indie studios to maintain live-service games. Market: TAM $180B — The global video game market, shifting toward decentralized governance models. | SAM $4.2B — Projected revenue for community-driven and mod-heavy live service titles. | SOM $950k — Facilitator fees from early-access competitive gaming communities on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Veto" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Transform game balance into a liquid market. Every vote, suggestion, or nerf-request costs 0.01 USDC via x402, eliminating bot spam and ensuring only skin-in-the-game players steer the meta. Developers receive a real-time revenue stream for community management while players receive a Base transaction hash as their receipt of influence. Discipline: Game Design & Interactive Media (community governance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving away from 'one-person-one-vote' (vulnerable to Sybil attacks) to 'pay-per-vote' (x402), the protocol filters for high-conviction players and provides a sustainable monetization model for indie studios to maintain live-service games. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Veto" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-proof-of-play-events-8-x402 Title: RallyPoint · x402 Theme: Game Design & Interactive Media (games) · event verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A secure 'Check-in as a Service' (CaaS) layer for live gaming events. Players pay 0.01 USDC to sign an HTS transfer attestation that anchors their geolocation and session metadata to the Base ledger. This micropayment eliminates bot-driven reward farming by enforcing a 'Proof-of-Skin' cost, enabling developers to distribute high-value RWA or in-game assets only to verified, paying participants without high gas overhead. Why Hedera: By moving event verification from a free 'claim' to a paid 'attestation,' developers filter out sybils. The x402 model makes the verification itself the product, creating a low-friction settlement layer for tournament entry or rare-drop eligibility. Market: TAM $2.8B — Global anti-cheat and participation-verification services in interactive media. | SAM $450M — On-chain gaming events and digital-physical crossover marketing. | SOM $12M — Hedera testnet gaming pilots and competitive e-sports event verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RallyPoint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A secure 'Check-in as a Service' (CaaS) layer for live gaming events. Players pay 0.01 USDC to sign an HTS transfer attestation that anchors their geolocation and session metadata to the Base ledger. This micropayment eliminates bot-driven reward farming by enforcing a 'Proof-of-Skin' cost, enabling developers to distribute high-value RWA or in-game assets only to verified, paying participants without high gas overhead. Discipline: Game Design & Interactive Media (event verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving event verification from a free 'claim' to a paid 'attestation,' developers filter out sybils. The x402 model makes the verification itself the product, creating a low-friction settlement layer for tournament entry or rare-drop eligibility. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RallyPoint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-onchain-multiplayer-matchmaking-9-x402 Title: LOBBY · x402 Theme: Game Design & Interactive Media (games) · match coordination Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — lobby-entry fee. A high-throughput matchmaking engine where every 'Queue' request is a micro-transaction. Players pay per match-seek, ensuring skin-in-the-game and eliminating 'ghost' matchmaking. The facilitator settles pairs instantly, returning an HTS transfer signed transaction that acts as the cryptographic handshake for game server entry. No subscription to play; pay only for the matches you actually find. Why Hedera: Traditional matchmaking is a server-side cost center. x402 turns it into a revenue-positive micro-service. By pricing the 'seek' action, it filters bot spam, rewards high-reputation coordinators, and enables a 'Pay-to-Queue' model that funds tournament prize pools in real-time. Market: TAM $4.2B — The global coordinated matchmaking and server-hosting market for cross-platform interactive media. | SAM $850M — The addressable market for competitive indie gaming and mid-core mobile titles moving to transparent, fee-per-session mechanics. | SOM $12M — Target volume for Alpha-testing on Hedera testnet, specifically focused on 1v1 skill-based games using HashPack for seamless EOA onboarding. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LOBBY" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — lobby-entry fee. A high-throughput matchmaking engine where every 'Queue' request is a micro-transaction. Players pay per match-seek, ensuring skin-in-the-game and eliminating 'ghost' matchmaking. The facilitator settles pairs instantly, returning an HTS transfer signed transaction that acts as the cryptographic handshake for game server entry. No subscription to play; pay only for the matches you actually find. Discipline: Game Design & Interactive Media (match coordination). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional matchmaking is a server-side cost center. x402 turns it into a revenue-positive micro-service. By pricing the 'seek' action, it filters bot spam, rewards high-reputation coordinators, and enables a 'Pay-to-Queue' model that funds tournament prize pools in real-time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LOBBY" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-rare-achievement-tokens-10-x402 Title: Glory Gate · x402 Theme: Game Design & Interactive Media (games) · achievement systems Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A universal achievement layer where game state validation is a paid primitive. Instead of free meaningless badges, players authorize a 0.01 USDC micropayment to settle a 'Proof of Skill' on-chain. This creates a high-signal leaderboard where every milestone has a marginal cost, filtering for true commitment. Developers earn per-unlock revenue, while player profiles serve as a sybil-resistant resume of gaming expertise. Why Hedera: By making the 'mint' or 'unlock' a pay-per-use event, the achievement gains economic weight. The x402 flow allows for frictionless, sub-cent validation that prevents bot-spamming rare trophies. Market: TAM $185B — The global gaming market, specifically targeting the shift toward digital asset ownership and verifiable achievements. | SAM $450M — On-chain gaming sub-sector and competitive e-sports badge markets. | SOM $12M — Indie developers on Hedera looking for non-intrusive monetization and verifiable player reputation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Glory Gate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A universal achievement layer where game state validation is a paid primitive. Instead of free meaningless badges, players authorize a 0.01 USDC micropayment to settle a 'Proof of Skill' on-chain. This creates a high-signal leaderboard where every milestone has a marginal cost, filtering for true commitment. Developers earn per-unlock revenue, while player profiles serve as a sybil-resistant resume of gaming expertise. Discipline: Game Design & Interactive Media (achievement systems). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making the 'mint' or 'unlock' a pay-per-use event, the achievement gains economic weight. The x402 flow allows for frictionless, sub-cent validation that prevents bot-spamming rare trophies. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Glory Gate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-onchain-virtual-economies-11-x402 Title: LootLogic · x402 Theme: Game Design & Interactive Media (games) · economy management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time economy balancing engine for game designers. Pay 0.01 USDC to simulate a 1,000-transaction stress test on your game's inflation curves or to mint dynamic loot-table adjustments. Every state-change in the economy requires an x402-authenticated signature, turning game management into a metered, audit-ready service where developers pay for computational 'god-mode' actions. Why Hedera: By shifting from a 'management dashboard' to a 'metered action' model, game studios can treat economy balancing as a variable cost tied to development activity. The x402 integration ensures every macro-economic intervention is cryptographically signed and paid for by the authorized designer. Market: TAM $180B — The global gaming market transitioning toward transparent, service-based backend infrastructure. | SAM $1.2B — Professional game designers and indie devs using onchain primitives for balancing. | SOM $45M — Web3 studios building on Hedera looking for programmatic economy-adjusting tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LootLogic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time economy balancing engine for game designers. Pay 0.01 USDC to simulate a 1,000-transaction stress test on your game's inflation curves or to mint dynamic loot-table adjustments. Every state-change in the economy requires an x402-authenticated signature, turning game management into a metered, audit-ready service where developers pay for computational 'god-mode' actions. Discipline: Game Design & Interactive Media (economy management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a 'management dashboard' to a 'metered action' model, game studios can treat economy balancing as a variable cost tied to development activity. The x402 integration ensures every macro-economic intervention is cryptographically signed and paid for by the authorized designer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LootLogic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-dynamic-nft-game-assets-12-x402 Title: ForgePath · x402 Theme: Game Design & Interactive Media (games) · asset evolution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Every XP gain, skin evolution, or weapon forge is a paid state transition. Use x402 to meter the 'Life Path' of your assets—0.01 USDC per mutation. Players don't just win items; they buy the right to evolve them via micro-transactions that settle on-chain instantly, making every visual upgrade a proof-of-investment. Fees go directly to the game balance for real-time asset upkeep. Why Hedera: By pricing the evolution event rather than the final asset, you create a high-velocity micro-economy. x402 replaces the 'grind' with a literal 'pay-to-grow' mechanic that is affordable but creates a continuous revenue stream for developers and a verifiable cost-basis for players. Market: TAM $18B — The global virtual goods and in-game transaction market moving toward transparent, asset-specific state updates. | SAM $420M — The projected market for 'interoperable' and 'evolvable' game assets across EVM-compatible gaming chains. | SOM $12M — Hyper-casual on-chain games on Hedera using HashPack for frictionless onboarding and micropayment-led progression. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ForgePath" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Every XP gain, skin evolution, or weapon forge is a paid state transition. Use x402 to meter the 'Life Path' of your assets—0.01 USDC per mutation. Players don't just win items; they buy the right to evolve them via micro-transactions that settle on-chain instantly, making every visual upgrade a proof-of-investment. Fees go directly to the game balance for real-time asset upkeep. Discipline: Game Design & Interactive Media (asset evolution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By pricing the evolution event rather than the final asset, you create a high-velocity micro-economy. x402 replaces the 'grind' with a literal 'pay-to-grow' mechanic that is affordable but creates a continuous revenue stream for developers and a verifiable cost-basis for players. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ForgePath" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-onchain-scavenger-hunts-13-x402 Title: Waypoint · x402 Theme: Game Design & Interactive Media (games) · interactive exploration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A location-based 'Proof of Presence' engine where every clue reveal and waypoint verification costs a 0.01 USDC micropayment. Players pay to unlock the next leg of the hunt, while creators earn instant, metered royalties for game design. Successful completion triggers a Base transaction hash that acts as an immutable voucher for physical rewards or digital loot. Why Hedera: By shifting from a free model to a pay-per-clue x402 primitive, the scavenger hunt becomes a self-sustaining economy. It prevents botting/spamming of game logic and allows creators to monetize 'micro-adventures' without high-friction subscription fees. Market: TAM $2.4B — Global interactive media and location-based entertainment market. | SAM $120M — Decentralized gaming and onchain 'Real World Activity' (RWA) platforms. | SOM $8M — Geofenced interactive marketing and immersive events on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Waypoint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A location-based 'Proof of Presence' engine where every clue reveal and waypoint verification costs a 0.01 USDC micropayment. Players pay to unlock the next leg of the hunt, while creators earn instant, metered royalties for game design. Successful completion triggers a Base transaction hash that acts as an immutable voucher for physical rewards or digital loot. Discipline: Game Design & Interactive Media (interactive exploration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a free model to a pay-per-clue x402 primitive, the scavenger hunt becomes a self-sustaining economy. It prevents botting/spamming of game logic and allows creators to monetize 'micro-adventures' without high-friction subscription fees. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Waypoint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-transparent-rng-mechanics-14-x402 Title: TrueSeed · x402 Theme: Game Design & Interactive Media (games) · game fairness Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: High-stakes game logic often suffers from 'house bias' skepticism. This service provides a verifiable, cryptographic entropy source via Hedera testnet. Players pay 0.01 USDC to trigger a 'Trustless Roll' signed by their Magic Link email sign-in. The payment acts as the commitment hash, ensuring the developer cannot manipulate the outcome after seeing the seed. Perfect for loot boxes, critical hits, or procedural map generation where fairness is a billable feature, not a promise. Why Hedera: By moving RNG from a hidden server function to a paid, user-initiated transaction, transparency becomes the product. The micropayment provides the economic audit trail for every 'luck' event in the game. Market: TAM $8.2B — The global online gambling and RNG-dependent gaming software market transitioning to verifiable primitives. | SAM $450M — The segment of the Web3 and mobile gaming market prioritizing provable fairness and on-chain transparency. | SOM $12M — Independent Unity and Godot developers on Hedera seeking a plug-and-play HTS transfer RNG oracle. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueSeed" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT High-stakes game logic often suffers from 'house bias' skepticism. This service provides a verifiable, cryptographic entropy source via Hedera testnet. Players pay 0.01 USDC to trigger a 'Trustless Roll' signed by their Magic Link email sign-in. The payment acts as the commitment hash, ensuring the developer cannot manipulate the outcome after seeing the seed. Perfect for loot boxes, critical hits, or procedural map generation where fairness is a billable feature, not a promise. Discipline: Game Design & Interactive Media (game fairness). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving RNG from a hidden server function to a paid, user-initiated transaction, transparency becomes the product. The micropayment provides the economic audit trail for every 'luck' event in the game. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueSeed" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-onchain-skill-trees-15-x402 Title: SkillPath · x402 Theme: Game Design & Interactive Media (games) · character progression Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Equip your character with high-fidelity traits by paying 0.01 USDC per node unlock. Each skill purchase is a cryptographic commitment to a specific build path, making specialized high-level builds rare and provably costly to attain. No more 'free' resets; deliberate progression creates market value for veteran accounts. Why Hedera: By shifting from simple logging to a pay-per-node model, character progression gains economic weight. The x402 primitive turns every skill choice into a micro-transactional event, preventing bot-spamming of maxed-out characters and ensuring 'meta' builds require real capital commitment. Market: TAM $180B — Global video game industry moving toward verifiable digital ownership. | SAM $450M — Revenue from on-chain gaming assets and specialized character markets. | SOM $12M — Hardcore RPG players on Hedera seeking provable, high-stakes progression systems. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SkillPath" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Equip your character with high-fidelity traits by paying 0.01 USDC per node unlock. Each skill purchase is a cryptographic commitment to a specific build path, making specialized high-level builds rare and provably costly to attain. No more 'free' resets; deliberate progression creates market value for veteran accounts. Discipline: Game Design & Interactive Media (character progression). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from simple logging to a pay-per-node model, character progression gains economic weight. The x402 primitive turns every skill choice into a micro-transactional event, preventing bot-spamming of maxed-out characters and ensuring 'meta' builds require real capital commitment. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SkillPath" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-decentralized-story-co-creation-16-x402 Title: Inkwell · x402 Theme: Game Design & Interactive Media (games) · collaborative narrative Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless writer's room where every plot twist costs 0.01 USDC. Writers pay to append a sentence to the world-state; readers pay to branch the narrative. The facilitator settles the 'Editor's Fee' instantly, turning collaborative lore into a real-time micro-economy where the most compelling story arcs generate the highest velocity of tx hashes. Why Hedera: By shifting from 'authorship recording' to 'metered contribution,' we solve the spam problem in collaborative writing. The cost to participate acts as a quality filter and a direct revenue stream for the story's treasury. Market: TAM $22B — The global interactive media and collaborative entertainment sector. | SAM $850M — The digital fiction and web-novel market transitioning to micro-transactional models. | SOM $12M — On-chain RPG communities and 'fictional world' DAOs on Hedera using x402 for lore-governance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Inkwell" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless writer's room where every plot twist costs 0.01 USDC. Writers pay to append a sentence to the world-state; readers pay to branch the narrative. The facilitator settles the 'Editor's Fee' instantly, turning collaborative lore into a real-time micro-economy where the most compelling story arcs generate the highest velocity of tx hashes. Discipline: Game Design & Interactive Media (collaborative narrative). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'authorship recording' to 'metered contribution,' we solve the spam problem in collaborative writing. The cost to participate acts as a quality filter and a direct revenue stream for the story's treasury. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Inkwell" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-onchain-game-jam-scores-17-x402 Title: HIGH SCORE · x402 Theme: Game Design & Interactive Media (games) · competition scoring Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A competitive leaderboard primitive where entries and votes are metered. Developers pay $0.01 to commit a high-score hash to the chain, and players pay $0.01 to verify their rank or cast a weighted vote. Frictionless, micro-stakes skin-in-the-game for global game jams. Why Hedera: Traditional game jams suffer from 'vote botting.' By requiring an HTS transfer signed micropayment for every score submission and vote, you create a sybil-resistant economic barrier that validates the integrity of the leaderboard without the friction of large transaction fees. Market: TAM $2.5B — The global eSports and casual competitive gaming infrastructure market. | SAM $480M — The estimated value of the hyper-casual game market and indie dev tools space seeking verifiable competition integrity. | SOM $12M — High-stakes indie game jams, hackathons, and speedrunning communities requiring verifiable audit trails. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "HIGH SCORE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A competitive leaderboard primitive where entries and votes are metered. Developers pay $0.01 to commit a high-score hash to the chain, and players pay $0.01 to verify their rank or cast a weighted vote. Frictionless, micro-stakes skin-in-the-game for global game jams. Discipline: Game Design & Interactive Media (competition scoring). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional game jams suffer from 'vote botting.' By requiring an HTS transfer signed micropayment for every score submission and vote, you create a sybil-resistant economic barrier that validates the integrity of the leaderboard without the friction of large transaction fees. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "HIGH SCORE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-player-created-onchain-items-18-x402 Title: ForgeStream · x402 Theme: Game Design & Interactive Media (games) · user-generated content Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A programmable forge for the agent-gaming era. Instead of static minting, players deploy interactive item logic gated by x402. Want to equip a legendary blade? Sign a 0.01 USDC HTS transfer request to 'unsheathe' the metadata. Creators get paid every time their item is activated, equipped, or triggered in-game, turning UGC from a one-time sale into a streaming royalty model. Processing happens on Hedera testnet, returning a tx hash as the 'proof of utility'. Why Hedera: Shifts UGC from 'ownership' to 'usage.' By micro-charging for the activation of item effects, creators earn based on the actual popularity and utility of their designs within the game loop. Market: TAM $120B — Total addressable market for the global virtual goods economy. | SAM $4.2B — Projected market for onchain UGC and interoperable gaming assets. | SOM $18M — Targeted revenue from high-frequency item triggers in indie-developed Hedera testnet RPGs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ForgeStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A programmable forge for the agent-gaming era. Instead of static minting, players deploy interactive item logic gated by x402. Want to equip a legendary blade? Sign a 0.01 USDC HTS transfer request to 'unsheathe' the metadata. Creators get paid every time their item is activated, equipped, or triggered in-game, turning UGC from a one-time sale into a streaming royalty model. Processing happens on Hedera testnet, returning a tx hash as the 'proof of utility'. Discipline: Game Design & Interactive Media (user-generated content). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts UGC from 'ownership' to 'usage.' By micro-charging for the activation of item effects, creators earn based on the actual popularity and utility of their designs within the game loop. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ForgeStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-onchain-vr-experience-logs-19-x402 Title: Vectra · x402 Theme: Game Design & Interactive Media (games) · immersive interaction Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Immersive interaction logs as a metered asset. Viewers pay $0.01 USDC to reconstruct a 3D replay of a VR session, or players pay per minute to broadcast their interaction telemetry to a live audience. Each HTS transfer signature unlocks a cryptographically signed packet of spatial data from the facilitator, enabling true pay-per-view immersive media. Why Hedera: Transfers VR data from a static record to a streaming commodity. By metering the access to interaction logs, creators can monetize high-skill gameplay or instructional spatial tutorials at a granular, per-session level via the embedded wallet's frictionless signing. Market: TAM $18B — The global spatial computing and XR hardware-software ecosystem. | SAM $420M — The emerging market for VR content creators, spatial influencers, and immersive remote-training modules. | SOM $12M — Early adopters in the VR gaming and metaverse space using Base for low-cost telemetry distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vectra" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Immersive interaction logs as a metered asset. Viewers pay $0.01 USDC to reconstruct a 3D replay of a VR session, or players pay per minute to broadcast their interaction telemetry to a live audience. Each HTS transfer signature unlocks a cryptographically signed packet of spatial data from the facilitator, enabling true pay-per-view immersive media. Discipline: Game Design & Interactive Media (immersive interaction). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transfers VR data from a static record to a streaming commodity. By metering the access to interaction logs, creators can monetize high-skill gameplay or instructional spatial tutorials at a granular, per-session level via the embedded wallet's frictionless signing. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vectra" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-blockchain-dialogue-trees-20-x402 Title: SCRIPTURA · x402 Theme: Game Design & Interactive Media (games) · interactive dialogue Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn narrative agency into a liquid commodity. Developers integrate a headless dialogue engine where every narrative branch is a 0.01 USDC transaction. Players don't just 'play'—they stake USDC to fork the story. Writers earn real-time royalties every time a player chooses their specific dialogue path, creating a high-stakes 'pay-to-speak' ecosystem where the most compelling choices generate the highest yield. Why Hedera: Current dialogue engines are static files. By making every node an x402 call, we turn interactive fiction into a micro-transactional economy. This incentivizes quality writing (high-traffic branches) and creates a verifiable ledger of 'canon' player choices. Market: TAM $18B — The global interactive media and 'serious games' market moving toward agentic, pay-per-interaction architectures. | SAM $420M — The indie narrative game market (Steam/itch.io) transitioning to micro-session models. | SOM $12M — Web3 visual novels and 'Choose Your Own Adventure' DAOs requiring trustless story branching. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SCRIPTURA" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn narrative agency into a liquid commodity. Developers integrate a headless dialogue engine where every narrative branch is a 0.01 USDC transaction. Players don't just 'play'—they stake USDC to fork the story. Writers earn real-time royalties every time a player chooses their specific dialogue path, creating a high-stakes 'pay-to-speak' ecosystem where the most compelling choices generate the highest yield. Discipline: Game Design & Interactive Media (interactive dialogue). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current dialogue engines are static files. By making every node an x402 call, we turn interactive fiction into a micro-transactional economy. This incentivizes quality writing (high-traffic branches) and creates a verifiable ledger of 'canon' player choices. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SCRIPTURA" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-onchain-speedrun-verification-21-x402 Title: SPRINT · x402 Theme: Game Design & Interactive Media (games) · challenge validation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Global Speedrun Oracle. Submit your frame-data payload for instant cryptographic validation. Gamers pay per verification to get on the global leaderboard; developers pay to batch-validate tournament entries. Eliminates manual modding through automated, state-stamped proof of completion. Every successful run returns a Hedera transaction id—your immutable receipt of glory. Why Hedera: Speedrunning suffers from a 'mod bottleneck' where humans must manually watch hours of footage. By turning validation into a pay-per-use micro-service (x402), we commoditize trust. The 0.01 USDC fee filters out spam attempts while providing a sustainable revenue model for the infra supporting the validation engine. Market: TAM $2.1B — The global anti-cheat and automated game officiating industry. | SAM $140M — The addressable competitive gaming and e-sports speedrun sub-market. | SOM $8.5M — Initial reach within indie game developers and onchain gaming ecosystems (FOCG) on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SPRINT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Global Speedrun Oracle. Submit your frame-data payload for instant cryptographic validation. Gamers pay per verification to get on the global leaderboard; developers pay to batch-validate tournament entries. Eliminates manual modding through automated, state-stamped proof of completion. Every successful run returns a Hedera transaction id—your immutable receipt of glory. Discipline: Game Design & Interactive Media (challenge validation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Speedrunning suffers from a 'mod bottleneck' where humans must manually watch hours of footage. By turning validation into a pay-per-use micro-service (x402), we commoditize trust. The 0.01 USDC fee filters out spam attempts while providing a sustainable revenue model for the infra supporting the validation engine. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SPRINT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-decentralized-game-mods-22-x402 Title: FORGE · x402 Theme: Game Design & Interactive Media (games) · mod distribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-pull mod injector for large-scale gaming ecosystems. Creators upload encrypted assets to IPFS; players trigger an x402 transaction (0.01 USDC) to authorize the client-side decryption key. By shifting from free distribution to micro-metered access, modders receive instant, automated settlement every time a player loads a custom skin, map, or script, turning 'modding' into a sustainable high-frequency streaming micro-economy. Why Hedera: Existing mod platforms rely on clunky donation links or ad-revenue sharing. By making the payment primitive (0.01 USDC per load), we create a high-velocity feedback loop where the best creators are compensated in real-time by the players actually using their content. Market: TAM $110B — The global PC and Console gaming market, increasingly driven by user-generated content and live-service mods. | SAM $850M — The addressable market for modding tools, asset stores, and private server plug-ins. | SOM $12M — Initial capture of the Base gaming ecosystem and indie developers integrating metered asset delivery. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FORGE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-pull mod injector for large-scale gaming ecosystems. Creators upload encrypted assets to IPFS; players trigger an x402 transaction (0.01 USDC) to authorize the client-side decryption key. By shifting from free distribution to micro-metered access, modders receive instant, automated settlement every time a player loads a custom skin, map, or script, turning 'modding' into a sustainable high-frequency streaming micro-economy. Discipline: Game Design & Interactive Media (mod distribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Existing mod platforms rely on clunky donation links or ad-revenue sharing. By making the payment primitive (0.01 USDC per load), we create a high-velocity feedback loop where the best creators are compensated in real-time by the players actually using their content. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FORGE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-token-gated-game-access-23-x402 Title: Arcade Gate · x402 Theme: Game Design & Interactive Media (games) · access control Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Move beyond all-or-nothing ownership. Gate high-stakes game states, secret levels, or powerful items behind 0.01 USDC x402 signatures. Players pay per respawn, per floor, or per interaction, creating a frictionless arcade-style economy where the facilitator settles state changes directly on Hedera. No inventory friction—just signed intent and instant play. Why Hedera: Traditional token-gating is binary and static; x402 turns game access into a flow. By metering specific interactions (e.g., 'Pay 0.01 USDC to enter the Boss Room'), developers capture value from every engagement while players avoid high upfront costs. This is the 'Insert Coin' primitive for the on-chain era. Market: TAM $190B — The global gaming market, increasingly shifting toward micro-transactions and session-based monetization. | SAM $850M — The projected market for casual/arcade-style on-chain games and pay-per-play mechanics. | SOM $12M — Target volume from hyper-casual Base-native games utilizing micropayment level-unlocks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Arcade Gate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Move beyond all-or-nothing ownership. Gate high-stakes game states, secret levels, or powerful items behind 0.01 USDC x402 signatures. Players pay per respawn, per floor, or per interaction, creating a frictionless arcade-style economy where the facilitator settles state changes directly on Hedera. No inventory friction—just signed intent and instant play. Discipline: Game Design & Interactive Media (access control). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional token-gating is binary and static; x402 turns game access into a flow. By metering specific interactions (e.g., 'Pay 0.01 USDC to enter the Boss Room'), developers capture value from every engagement while players avoid high upfront costs. This is the 'Insert Coin' primitive for the on-chain era. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Arcade Gate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-onchain-interactive-artpieces-24-x402 Title: FluxState · x402 Theme: Game Design & Interactive Media (games) · generative art Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Generative canvases where every brushstroke or state-shift is a transaction. Users pay 0.01 USDC to trigger a mutation of the piece's visual logic. Artists earn direct, granular royalties as collectors and bots interact with the live code to evolve the aesthetic. Payment is the literal 'shutter click' for capturing and altering the generative flow. Why Hedera: Shifting from static ownership to interactive micropayments allows generative art to become a living, revenue-generating engine rather than a one-time sale. x402 handles the high-frequency state changes that are cost-prohibitive with standard gas fees. Market: TAM $3.8B — Global digital art and interactive media market shifting toward programmable assets. | SAM $420M — Professional generative art collectors and interactive installation enthusiasts. | SOM $12M — Onchain generative art 'degen' collectors and autonomous art-curator agents. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FluxState" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Generative canvases where every brushstroke or state-shift is a transaction. Users pay 0.01 USDC to trigger a mutation of the piece's visual logic. Artists earn direct, granular royalties as collectors and bots interact with the live code to evolve the aesthetic. Payment is the literal 'shutter click' for capturing and altering the generative flow. Discipline: Game Design & Interactive Media (generative art). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from static ownership to interactive micropayments allows generative art to become a living, revenue-generating engine rather than a one-time sale. x402 handles the high-frequency state changes that are cost-prohibitive with standard gas fees. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FluxState" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-pixel-lore-vault-0-x402 Title: Grimoire · x402 Theme: Game Design & Interactive Media (games) · game narrative archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Lore is the ultimate metadata. Pixel Lore Vault converts crowd-sourced game world-building into a metered library. Players pay 0.01 USDC to canonize a plot point, commit an NPC's fate, or query the 'World Bible' for AI-driven roleplay consistency. Narrative persistence is no longer a volunteer effort; it's a high-integrity, paid-access historical record where contributors earn from the provenance of their storytelling. Why Hedera: Traditional wikis suffer from low-intent spam. By introducing an x402 cost per entry and per deep-query, the vault ensures high-fidelity lore while enabling narrative designers to monetize the 'Extended Universe' of their games directly through player interaction. Market: TAM $2.8B — Global market for transmedia storytelling, game wiki ecosystems, and interactive lore-rich IP. | SAM $450M — Revenue potential from the RPG and MMORPG narrative design and fan-fiction economy. | SOM $12M — On-chain lore management for the burgeoning niche of autonomous worlds and FOCG (Fully On-Chain Games). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Grimoire" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Lore is the ultimate metadata. Pixel Lore Vault converts crowd-sourced game world-building into a metered library. Players pay 0.01 USDC to canonize a plot point, commit an NPC's fate, or query the 'World Bible' for AI-driven roleplay consistency. Narrative persistence is no longer a volunteer effort; it's a high-integrity, paid-access historical record where contributors earn from the provenance of their storytelling. Discipline: Game Design & Interactive Media (game narrative archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional wikis suffer from low-intent spam. By introducing an x402 cost per entry and per deep-query, the vault ensures high-fidelity lore while enabling narrative designers to monetize the 'Extended Universe' of their games directly through player interaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Grimoire" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-avatar-trait-forge-1-x402 Title: Trait Smith · x402 Theme: Game Design & Interactive Media (games) · character customization data Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Forge, mint, and lock modular character metadata into portable identity-blobs. Every parameter change or skin-shard export triggers a 0.01 USDC micro-settlement. Secure your aesthetic IP across the metaverse—pay only for the traits you actually equip. Why Hedera: Shifts the model from a 'storage' utility to an 'active fabrication' utility. By metering the saving and sharing of traits, you prevent database bloat and create a value-link between the designer and the player at the point of creation. Market: TAM $12.5B — Global market for in-game cosmetic assets and character customization suites. | SAM $450M — The interoperable skin-trading and asset-management layer for web3 gaming. | SOM $12M — Indie RPG developers and DAOs using x402 to monetize character prefab libraries. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Trait Smith" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Forge, mint, and lock modular character metadata into portable identity-blobs. Every parameter change or skin-shard export triggers a 0.01 USDC micro-settlement. Secure your aesthetic IP across the metaverse—pay only for the traits you actually equip. Discipline: Game Design & Interactive Media (character customization data). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from a 'storage' utility to an 'active fabrication' utility. By metering the saving and sharing of traits, you prevent database bloat and create a value-link between the designer and the player at the point of creation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Trait Smith" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-quest-chain-archive-2-x402 Title: Quest Node · x402 Theme: Game Design & Interactive Media (games) · interactive quest design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized vault of high-fidelity quest logic where game engines and dungeon masters pay per branch. Instead of opaque game files, Quest Node serves HTS transfer signed decision trees. A 0.01 USDC micro-transaction unlocks the next narrative state, validates player inventory prerequisites, and manages state transitions on-chain. Developers can subscribe their NPCs to this logic or players can pay to 'unlock' hidden lore paths in real-time. Why Hedera: By turning quest logic into a metered API, narrative designers monetize the 'intelligence' of their world-building. x402 allows for granular story-gating where the cost of the adventure scales with the complexity of the player's choices, creating a direct value link between writing and gameplay. Market: TAM $180B — The global video game market, specifically shifting toward user-generated content and modifiable narrative systems. | SAM $1.5B — Independent and Web3 game developers utilizing programmable storytelling and modular game assets. | SOM $18M — The emerging 'On-chain RPG' and 'AI Dungeon' niche where narrative state must be provable and persistent. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Quest Node" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized vault of high-fidelity quest logic where game engines and dungeon masters pay per branch. Instead of opaque game files, Quest Node serves HTS transfer signed decision trees. A 0.01 USDC micro-transaction unlocks the next narrative state, validates player inventory prerequisites, and manages state transitions on-chain. Developers can subscribe their NPCs to this logic or players can pay to 'unlock' hidden lore paths in real-time. Discipline: Game Design & Interactive Media (interactive quest design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning quest logic into a metered API, narrative designers monetize the 'intelligence' of their world-building. x402 allows for granular story-gating where the cost of the adventure scales with the complexity of the player's choices, creating a direct value link between writing and gameplay. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Quest Node" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-xr-scene-snapshot-3-x402 Title: SceneFreeze · x402 Theme: Game Design & Interactive Media (games) · extended reality content storage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A spatial bookmarking protocol that leverages x402 to capture, store, and retrieval XR environment snapshots. Users pay 0.01 USDC to 'freeze' a 3D scene state or 'thaw' a premium environment for instant cross-platform loading. Developers integrate the API to allow AI agents to navigate or populate persistent AR world-layers via micropayment-gated metadata retrieval. Why Hedera: By turning state persistence into a metered commodity, XR developers avoid heavy server overhead while creators monetize high-fidelity environment configurations. The x402 model ensures that only the data retrieved is paid for, enabling a granular marketplace for immersive context. Market: TAM $4.2B — The global Metaverse infrastructure and real-time 3D (RT3D) content management market. | SAM $850M — The projected market for specialized XR cloud storage and spatial mapping services by 2026. | SOM $12M — Initial volume from independent XR developers and spatial web agencies using Base for low-cost asset state persistence. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SceneFreeze" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A spatial bookmarking protocol that leverages x402 to capture, store, and retrieval XR environment snapshots. Users pay 0.01 USDC to 'freeze' a 3D scene state or 'thaw' a premium environment for instant cross-platform loading. Developers integrate the API to allow AI agents to navigate or populate persistent AR world-layers via micropayment-gated metadata retrieval. Discipline: Game Design & Interactive Media (extended reality content storage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning state persistence into a metered commodity, XR developers avoid heavy server overhead while creators monetize high-fidelity environment configurations. The x402 model ensures that only the data retrieved is paid for, enabling a granular marketplace for immersive context. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SceneFreeze" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-procedural-map-depository-4-x402 Title: TERRAFORM · x402 Theme: Game Design & Interactive Media (games) · map generation and sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized vault for procedural seeds and level geometry where every dungeon crawl or terrain mesh is a micro-asset. Level designers set a 0.01 USDC fee for 'Export-to-Engine' calls, allowing indie devs to programmatically pull high-quality, pre-tested world data directly into Unity or Unreal via x402-gated API endpoints. Stop hosting static files; start streaming worlds. Why Hedera: By placing the paywall at the 'Export' call rather than a subscription, it turns map generation into a utility-metered service. This empowers solo developers to monetize their procedural algorithms and allows game engines to function as automated buyers. Market: TAM $190B — The global gaming market, increasingly reliant on procedural generation to offset rising AAA developer costs. | SAM $850M — The indie and middle-market developer tool spend, transitioning toward 'pay-per-asset' cloud workflows. | SOM $12M — Early-adopter procedural generation specialists and roguelike developers using Base for cross-game asset interoperability. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TERRAFORM" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized vault for procedural seeds and level geometry where every dungeon crawl or terrain mesh is a micro-asset. Level designers set a 0.01 USDC fee for 'Export-to-Engine' calls, allowing indie devs to programmatically pull high-quality, pre-tested world data directly into Unity or Unreal via x402-gated API endpoints. Stop hosting static files; start streaming worlds. Discipline: Game Design & Interactive Media (map generation and sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By placing the paywall at the 'Export' call rather than a subscription, it turns map generation into a utility-metered service. This empowers solo developers to monetize their procedural algorithms and allows game engines to function as automated buyers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TERRAFORM" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-artifact-provenance-ledger-5-x402 Title: LoreGate · x402 Theme: Game Design & Interactive Media (games) · in-game item history tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Immutable item diaries for high-stakes gaming. Pay 0.01 USDC to append a new 'Life Chapter' to an artifact's metadata (kills, previous owners, historic battles). Every epic loot drop becomes a living document, monetizing the fame of the player who wielded it. Why Hedera: By turning provenance into a pay-per-event write operation, the ledger moves from a static database to a metered narrative engine. It creates a 'prestige tax' where players pay to cement their legacy on the item's on-chain history. Market: TAM $14.5B — The global virtual goods skin and item trading market. | SAM $850M — On-chain gaming assets and secondary market validation services. | SOM $12M — Hardcore competitive RPG players and rare-item collectors on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LoreGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Immutable item diaries for high-stakes gaming. Pay 0.01 USDC to append a new 'Life Chapter' to an artifact's metadata (kills, previous owners, historic battles). Every epic loot drop becomes a living document, monetizing the fame of the player who wielded it. Discipline: Game Design & Interactive Media (in-game item history tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning provenance into a pay-per-event write operation, the ledger moves from a static database to a metered narrative engine. It creates a 'prestige tax' where players pay to cement their legacy on the item's on-chain history. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LoreGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-audio-loop-library-6-x402 Title: Sonic Ledger · x402 Theme: Game Design & Interactive Media (games) · sound asset curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized 'drum machine' of premium game loops where every play-test is free, but every high-fidelity 'Export to Engine' or 'Remix' action triggers a 0.01 USDC micro-license. Developers can automate their soundscapes by hooking game state triggers directly to the x402 endpoint, paying creators in real-time as assets are pulled into the build. Why Hedera: By moving from a subscription model to a per-pull micropayment, indie devs avoid heavy upfront asset costs, and sound designers receive a continuous stream of revenue proportional to their loop's popularity in active projects. Market: TAM $2.8B — The global game audio and SFX middleware market moving toward generative and on-demand delivery. | SAM $450M — The independent game developer and procedural music market using real-time asset streaming. | SOM $12M — Indie studios on Hedera leveraging automated asset pipelines and automated licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sonic Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized 'drum machine' of premium game loops where every play-test is free, but every high-fidelity 'Export to Engine' or 'Remix' action triggers a 0.01 USDC micro-license. Developers can automate their soundscapes by hooking game state triggers directly to the x402 endpoint, paying creators in real-time as assets are pulled into the build. Discipline: Game Design & Interactive Media (sound asset curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a subscription model to a per-pull micropayment, indie devs avoid heavy upfront asset costs, and sound designers receive a continuous stream of revenue proportional to their loop's popularity in active projects. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sonic Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-interactive-comic-archive-7-x402 Title: InkPath · x402 Theme: Game Design & Interactive Media (games) · visual storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Panels are locked by smart contracts; readers authorize a 0.01 USDC micropayment to 'turn the page' or branch the story. Each transaction on Hedera testnet records the reader's narrative choice, dynamically generating the next arc while ensuring creators are paid per view. No subscriptions, just a pay-per-frame primitive that turns reading into a micro-transactional game loop. Why Hedera: Linear media lacks granular monetization; x402 enables a 'pay-as-you-read' model where the marginal cost of a story beat is negligible for the user but provides immediate, scalable liquidity for the artist. Market: TAM $12B — The total addressable market for global digital comics and self-publishing platforms. | SAM $1.4B — The projected market for digital webtoons and interactive fiction platforms adopting Web3 primitives. | SOM $85M — Independent visual storytellers on Hedera utilizing per-panel micropayments. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkPath" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Panels are locked by smart contracts; readers authorize a 0.01 USDC micropayment to 'turn the page' or branch the story. Each transaction on Hedera testnet records the reader's narrative choice, dynamically generating the next arc while ensuring creators are paid per view. No subscriptions, just a pay-per-frame primitive that turns reading into a micro-transactional game loop. Discipline: Game Design & Interactive Media (visual storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Linear media lacks granular monetization; x402 enables a 'pay-as-you-read' model where the marginal cost of a story beat is negligible for the user but provides immediate, scalable liquidity for the artist. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkPath" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-ai-npc-memory-hub-8-x402 Title: NEURAFLOW · x402 Theme: Game Design & Interactive Media (games) · non-player character data Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A persistent state layer for autonomous game entities. Every time an NPC 'remembers' a player interaction, retrieves a past dialogue, or updates its emotional bias, the game engine triggers an x402-signed call. Instead of flat server costs, NPCs act as autonomous accounts that pay 0.01 USDC to write/retrieve their own history on Hedera. Payment is the literal cost of cognition and permanence. Why Hedera: Game developers currently struggle with the high cost of persistent LLM memory. By moving memory to a pay-per-read/write model settled on Hedera, NPCs become portable assets across different game worlds, carrying their history via self-sovereign micropayments. Market: TAM $250B — The global gaming market, transitioning toward AI-driven persistent multiverses. | SAM $850M — The specialized market for AI-driven game narrative tools and procedural content generation. | SOM $12M — Indie RPG and Web3 game developers integrating autonomous agentic NPCs on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "NEURAFLOW" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A persistent state layer for autonomous game entities. Every time an NPC 'remembers' a player interaction, retrieves a past dialogue, or updates its emotional bias, the game engine triggers an x402-signed call. Instead of flat server costs, NPCs act as autonomous accounts that pay 0.01 USDC to write/retrieve their own history on Hedera. Payment is the literal cost of cognition and permanence. Discipline: Game Design & Interactive Media (non-player character data). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Game developers currently struggle with the high cost of persistent LLM memory. By moving memory to a pay-per-read/write model settled on Hedera, NPCs become portable assets across different game worlds, carrying their history via self-sovereign micropayments. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "NEURAFLOW" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-glyph-pattern-codex-9-x402 Title: GLYPH CODEX · x402 Theme: Game Design & Interactive Media (games) · symbol and icon design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pressurized library of vector-based icon system components where designers earn per-asset pull. Instead of licensing entire packs, developers pay $0.01 per SVG call to inject high-fidelity symbols directly into game engines or web environments. Every 'Get Component' request generates an immediate x402 settlement, turning icon libraries into programmatic, pay-per-use APIs for dynamic UI generation. Why Hedera: Moving from bulk licensing to per-render micropayments aligns with the needs of procedural game generation and automated design tools (AI agents) that require just-in-time assets without overhead. Market: TAM $4.2B — The global creative asset & stock media market transitioning to granular, API-driven distribution. | SAM $850M — The market for UI/UX assets, digital icon packs, and programmable design tokens. | SOM $12M — Indie game developers and AI UI generators using on-demand icon retrieval on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GLYPH CODEX" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pressurized library of vector-based icon system components where designers earn per-asset pull. Instead of licensing entire packs, developers pay $0.01 per SVG call to inject high-fidelity symbols directly into game engines or web environments. Every 'Get Component' request generates an immediate x402 settlement, turning icon libraries into programmatic, pay-per-use APIs for dynamic UI generation. Discipline: Game Design & Interactive Media (symbol and icon design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from bulk licensing to per-render micropayments aligns with the needs of procedural game generation and automated design tools (AI agents) that require just-in-time assets without overhead. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GLYPH CODEX" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-mod-manifest-vault-10-x402 Title: ModHash · x402 Theme: Game Design & Interactive Media (games) · game modification metadata Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to sign a cryptographically verifiable manifest for any game mod. Every time a launcher or player-client fetches your optimized metadata/dependency tree to automate an install, you earn. Modding becomes a metered infrastructure layer where the most reliable configurations generate passive revenue based on utility. Why Hedera: Moving mod discovery from 'charity-hosted forums' to a 'pay-per-query manifest vault' ensures uptime and rewards the technical labor of resolving version conflicts and dependency hell. Market: TAM $2.8B — The global PC game modification and user-generated content infrastructure market. | SAM $350M — Revenue potential from automated mod-manager API calls and high-frequency manifest verification. | SOM $12M — Initial capture of the Base-native gaming community and decentralized game engine integrators. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ModHash" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to sign a cryptographically verifiable manifest for any game mod. Every time a launcher or player-client fetches your optimized metadata/dependency tree to automate an install, you earn. Modding becomes a metered infrastructure layer where the most reliable configurations generate passive revenue based on utility. Discipline: Game Design & Interactive Media (game modification metadata). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving mod discovery from 'charity-hosted forums' to a 'pay-per-query manifest vault' ensures uptime and rewards the technical labor of resolving version conflicts and dependency hell. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ModHash" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-dynamic-ui-blueprint-11-x402 Title: STASIS · x402 Theme: Game Design & Interactive Media (games) · interface state storage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Export or snapshot granular interface configurations (HUD layouts, keybind matrices, control sensitivity) as portable on-chain assets. Pay per state-save or state-load. Developers pay to query community-vetted UI presets for their own game instances, ensuring cross-platform consistency for pro-players and accessibility-needs users via instant HTS transfer settlement. Why Hedera: Decouples UI state from local storage and siloed cloud servers, turning 'UX ergonomics' into a liquified asset class. Use x402 to meter the high-frequency reads/writes of UI state synchronization in competitive gaming. Market: TAM $180B — The global gaming market transitioning toward decentralized asset ownership and interoperable player profiles. | SAM $940M — The addressable market for middleware specializing in cross-platform UI/UX synchronization and social-sharing of gaming configurations. | SOM $12M — The immediate niche of competitive FPS and MMO players paying to clone the exact interface states of top-tier pros via automated wallet signatures. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STASIS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Export or snapshot granular interface configurations (HUD layouts, keybind matrices, control sensitivity) as portable on-chain assets. Pay per state-save or state-load. Developers pay to query community-vetted UI presets for their own game instances, ensuring cross-platform consistency for pro-players and accessibility-needs users via instant HTS transfer settlement. Discipline: Game Design & Interactive Media (interface state storage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Decouples UI state from local storage and siloed cloud servers, turning 'UX ergonomics' into a liquified asset class. Use x402 to meter the high-frequency reads/writes of UI state synchronization in competitive gaming. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STASIS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-lore-collaboration-board-12-x402 Title: CANON · x402 Theme: Game Design & Interactive Media (games) · community storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn world-building into a high-stakes protocol. Users pay 0.01 USDC to 'canonize' a story beat or character attribute. Each payment triggers an HTS transfer transfer that anchors the contribution to the immutable narrative branch on Hedera. The facilitator settles the transaction, returning a tx hash that serves as the permanent 'Proof of Lore.' Community members can tip specific contributors to boost their narrative influence, creating a financially backed consensus layer for collective fiction. Why Hedera: By shifting from 'free voting' to 'micropayment-gated canonization,' the project filters noise and introduces economic scarcity to narrative design. The x402 model ensures that every addition to the lore has a verifiable cost-of-entry, protecting the narrative from spam while compensating the protocol. Market: TAM $2.4B — The global interactive fiction and transmedia storytelling market, increasingly moving toward user-generated content. | SAM $150M — The emerging 'Fiction-to-Earn' and collaborative world-building platforms utilizing micro-incentives. | SOM $12M — Community-run RPG servers, DAOs, and indie game fandoms looking for decentralized lore-governance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CANON" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn world-building into a high-stakes protocol. Users pay 0.01 USDC to 'canonize' a story beat or character attribute. Each payment triggers an HTS transfer transfer that anchors the contribution to the immutable narrative branch on Hedera. The facilitator settles the transaction, returning a tx hash that serves as the permanent 'Proof of Lore.' Community members can tip specific contributors to boost their narrative influence, creating a financially backed consensus layer for collective fiction. Discipline: Game Design & Interactive Media (community storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'free voting' to 'micropayment-gated canonization,' the project filters noise and introduces economic scarcity to narrative design. The x402 model ensures that every addition to the lore has a verifiable cost-of-entry, protecting the narrative from spam while compensating the protocol. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CANON" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-playable-prop-archive-13-x402 Title: PROP-FETCH · x402 Theme: Game Design & Interactive Media (games) · asset permanence Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cross-engine protocol for game asset instantiation. Developers pay 0.01 USDC to fetch a cryptographically verified 'Prop Bundle' (3D mesh, collision data, and physics scripts) from the Base ledger into their runtime. Payment guarantees the asset’s permanent availability and provenance, preventing broken dependencies in persistent worlds. Creators receive instant micropayment royalties every time their prop is spawned in a new game instance. Why Hedera: Traditional asset stores use heavy upfront licenses. By moving to a pay-per-instantiation model, indie devs reduce overhead while creators generate high-volume, passive revenue from 'viral' assets used across the metaverse. Market: TAM $18.4B — The global game engine asset and digital twin market moving toward standardized, metered interoperability. | SAM $850M — The addressable market for indie/AA game developers and modders migrating to on-chain asset libraries. | SOM $12M — Initial capture of the Base gaming ecosystem and interoperable 'on-chain world' plugins. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PROP-FETCH" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cross-engine protocol for game asset instantiation. Developers pay 0.01 USDC to fetch a cryptographically verified 'Prop Bundle' (3D mesh, collision data, and physics scripts) from the Base ledger into their runtime. Payment guarantees the asset’s permanent availability and provenance, preventing broken dependencies in persistent worlds. Creators receive instant micropayment royalties every time their prop is spawned in a new game instance. Discipline: Game Design & Interactive Media (asset permanence). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional asset stores use heavy upfront licenses. By moving to a pay-per-instantiation model, indie devs reduce overhead while creators generate high-volume, passive revenue from 'viral' assets used across the metaverse. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PROP-FETCH" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-speedrun-data-ledger-14-x402 Title: FramePerfect · x402 Theme: Game Design & Interactive Media (games) · game performance recording Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity 'black box' for speedrunners. Pay 0.05 USDC to seal a cryptographic proof of your run, including controller inputs and memory state JSONs, directly to Base. No more screen-cap disputes; verify integrity via on-chain state transitions. Pay to play, pay to verify. Why Hedera: By moving speedrun validation from subjective video review to objective data-ledger verification, the app creates a 'Proof of Skill' primitive. Micropayments frictionlessly gate the submission of high-volume replay data, preventing spam while funding the decentralized indexers that verify run legitimacy. Market: TAM $2.1B — The global game analytics and competitive integrity middleware market. | SAM $450M — The competitive gaming and high-stakes eSports validation market. | SOM $12M — Hardcore speedrunning communities (GDQ, Speedrun.com) requiring immutable anti-cheat data logs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FramePerfect" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity 'black box' for speedrunners. Pay 0.05 USDC to seal a cryptographic proof of your run, including controller inputs and memory state JSONs, directly to Base. No more screen-cap disputes; verify integrity via on-chain state transitions. Pay to play, pay to verify. Discipline: Game Design & Interactive Media (game performance recording). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving speedrun validation from subjective video review to objective data-ledger verification, the app creates a 'Proof of Skill' primitive. Micropayments frictionlessly gate the submission of high-volume replay data, preventing spam while funding the decentralized indexers that verify run legitimacy. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FramePerfect" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-virtual-stage-archives-15-x402 Title: PROSCENIUM · x402 Theme: Game Design & Interactive Media (games) · interactive performance spaces Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized locker for XR scenography. Pay 0.01 USDC per asset retrieval or scene manifest fetch. Designers earn on every 'instantiate' call during live performances, making stagecraft a liquid, pay-per-use utility for virtual touring. Why Hedera: Transforming a static archive into a metered delivery system ensures creators are compensated for the exact volume of use during events, preventing bulk piracy of complex XR environments. Market: TAM $4.2B — The global live event production and stage design industry transitioning to hybrid/virtual formats. | SAM $280M — The growing market for XR-live events and 'Metaverse' performance licensing. | SOM $12M — Specialized technical directors and indie XR creators seeking transparent per-show royalty tracking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PROSCENIUM" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized locker for XR scenography. Pay 0.01 USDC per asset retrieval or scene manifest fetch. Designers earn on every 'instantiate' call during live performances, making stagecraft a liquid, pay-per-use utility for virtual touring. Discipline: Game Design & Interactive Media (interactive performance spaces). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transforming a static archive into a metered delivery system ensures creators are compensated for the exact volume of use during events, preventing bulk piracy of complex XR environments. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PROSCENIUM" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-emotion-filter-pack-16-x402 Title: Sentience Spark · x402 Theme: Game Design & Interactive Media (games) · visual effect assets Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A programmable VFX engine for on-chain characters. Instead of buying a static pack, developers and creators call specific mood-based shaders (Rage, Melancholy, Ethereal) via API. Each rendering call triggers a 0.01 USDC micro-settlement. Secure your cinematic aesthetic by paying only for the frames you bake or the sessions you stream. Why Hedera: Shifting from 'asset packs' to 'VFX-as-a-Service' prevents piracy and lowers the barrier for indie devs. By metering the filter application, the VFX artist receives continuous micro-royalties every time a player triggers a 'Power Up' or 'Death' screen visual effect. Market: TAM $15.6B — The global Visual Effects (VFX) and real-time rendering software market. | SAM $220M — The growing 'plug-and-play' middleware market for indie game devs and social app filters. | SOM $12M — Specialized micro-transaction volume for high-fidelity Web3 gaming emotes and social visual effects. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sentience Spark" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A programmable VFX engine for on-chain characters. Instead of buying a static pack, developers and creators call specific mood-based shaders (Rage, Melancholy, Ethereal) via API. Each rendering call triggers a 0.01 USDC micro-settlement. Secure your cinematic aesthetic by paying only for the frames you bake or the sessions you stream. Discipline: Game Design & Interactive Media (visual effect assets). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from 'asset packs' to 'VFX-as-a-Service' prevents piracy and lowers the barrier for indie devs. By metering the filter application, the VFX artist receives continuous micro-royalties every time a player triggers a 'Power Up' or 'Death' screen visual effect. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sentience Spark" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-puzzle-logic-cache-17-x402 Title: Enigma Grid · x402 Theme: Game Design & Interactive Media (games) · game mechanic storage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A programmable vault for procedural game logic. Designers publish puzzle 'blueprints' (state machines, move-sets, solution trees) that cost $0.01 per import or validation. Players pay to verify a solution against the immutable cache, while developers pay to remix existing archetypes into new levels. Payment is the gravity that keeps the puzzle economy balanced—no free solves, no stolen logic. Why Hedera: By moving puzzle validation off-client and onto a pay-per-call infrastructure, you turn game design into a series of micro-transactions. This prevents 'spoiler' data mining and creates a direct revenue stream for mechanics designers. Market: TAM $2.8B — The global 'Logic & Puzzle' mobile gaming market shifting toward decentralized, peer-to-peer asset sharing. | SAM $145M — On-chain game developers and modular 'autonomous world' creators requiring verifiable state logic. | SOM $8M — Indie developers on Hedera implementing HTS transfer for anti-cheat and user-generated content logic. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Enigma Grid" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A programmable vault for procedural game logic. Designers publish puzzle 'blueprints' (state machines, move-sets, solution trees) that cost $0.01 per import or validation. Players pay to verify a solution against the immutable cache, while developers pay to remix existing archetypes into new levels. Payment is the gravity that keeps the puzzle economy balanced—no free solves, no stolen logic. Discipline: Game Design & Interactive Media (game mechanic storage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving puzzle validation off-client and onto a pay-per-call infrastructure, you turn game design into a series of micro-transactions. This prevents 'spoiler' data mining and creates a direct revenue stream for mechanics designers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Enigma Grid" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-lore-token-index-18-x402 Title: Mythos Query · x402 Theme: Game Design & Interactive Media (games) · metadata indexing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency indexing service where metadata retrieval is metered. Developers and game engines pay 0.01 USDC per HTS transfer signed request to instantly resolve a Lore Token's IPFS manifest. Instead of bulk API subscriptions, pay only for the specific narrative assets your players trigger in-game. Settlement happens instantly on Hedera. Why Hedera: Lore indexing is often a bottleneck in decentralized gaming. By moving from a subscription model to a pay-per-resolution (x402) model, small developers can scale from 1 to 1,000,000 items without upfront costs, while the indexer earns immediate, liquid USDC for every read operation. Market: TAM $8.4B — The global game engine metadata and asset management market moving on-chain. | SAM $420M — Web3 game developers and narrative-heavy dApps requiring real-time metadata resolution. | SOM $12M — Independent game studios on Hedera using IPFS-based asset registries. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Mythos Query" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency indexing service where metadata retrieval is metered. Developers and game engines pay 0.01 USDC per HTS transfer signed request to instantly resolve a Lore Token's IPFS manifest. Instead of bulk API subscriptions, pay only for the specific narrative assets your players trigger in-game. Settlement happens instantly on Hedera. Discipline: Game Design & Interactive Media (metadata indexing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Lore indexing is often a bottleneck in decentralized gaming. By moving from a subscription model to a pay-per-resolution (x402) model, small developers can scale from 1 to 1,000,000 items without upfront costs, while the indexer earns immediate, liquid USDC for every read operation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Mythos Query" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-multiplayer-mod-sync-19-x402 Title: OmniSync · x402 Theme: Game Design & Interactive Media (games) · cross-user asset distribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-sync node discovery. Modern multiplayer environments suffer from version drift. This x402 utility allows game clients to programmatically 'lease' a verified asset manifest from a host or IPFS peer. Payment triggers the cryptographic unlock of the CID manifest, ensuring every player in the lobby is running the exact same byte-code and 3D assets. Eliminates server-side storage costs by turning peers into paid content delivery nodes. Why Hedera: The friction in modded multiplayer is the 'join-and-fail' loop. By making the manifest a paid x402 unlock, you incentivize high-bandwidth peers to act as reliable seeders for large modpacks, creating a self-sustaining asset distribution network where the cost to sync is negligible for the player but significant for the network reliability. Market: TAM $15.4B — the global PC gaming and middleware market, specifically looking at transition to distributed cloud-based asset streaming and user-generated content (UGC) ecosystems. | SAM $820M — modding communities, private server operators (Minecraft, Garry's Mod, FiveM), and indie multiplayer developers seeking decentralized patch distribution. | SOM $12M — early adopters in the Web3 gaming space and open-source engine contributors (Godot/Bevy) implementing automated asset reconciliation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "OmniSync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-sync node discovery. Modern multiplayer environments suffer from version drift. This x402 utility allows game clients to programmatically 'lease' a verified asset manifest from a host or IPFS peer. Payment triggers the cryptographic unlock of the CID manifest, ensuring every player in the lobby is running the exact same byte-code and 3D assets. Eliminates server-side storage costs by turning peers into paid content delivery nodes. Discipline: Game Design & Interactive Media (cross-user asset distribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: The friction in modded multiplayer is the 'join-and-fail' loop. By making the manifest a paid x402 unlock, you incentivize high-bandwidth peers to act as reliable seeders for large modpacks, creating a self-sustaining asset distribution network where the cost to sync is negligible for the player but significant for the network reliability. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "OmniSync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-interactive-soundscapes-20-x402 Title: VibeStream · x402 Theme: Game Design & Interactive Media (games) · ambient audio curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A library of procedural, layered audio stems that game engines pull via x402-metered triggers. Instead of a flat loops, developers pay $0.01 to dynamically call 'atmospheric shifts' (e.g., weather changes, tension spikes) within a persistent world. Every interactive state change triggers a micropayment to the spatial sound designer, turning background noise into a liquid, pay-on-demand asset for indie dev and AI-driven environments. Why Hedera: Standard audio licensing is rigid; x402 allows for 'Ambient-as-a-Service' where the music budget scales exactly with the player's world interactions. Market: TAM $22B — The total addressable market for game design engines and spatial computing assets. | SAM $850M — The interactive audio and game-asset marketplace sector. | SOM $12M — Indie developers and procedural world-builders on Hedera seeking a dynamic, low-overhead alternative to FMOD/Wwise licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VibeStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A library of procedural, layered audio stems that game engines pull via x402-metered triggers. Instead of a flat loops, developers pay $0.01 to dynamically call 'atmospheric shifts' (e.g., weather changes, tension spikes) within a persistent world. Every interactive state change triggers a micropayment to the spatial sound designer, turning background noise into a liquid, pay-on-demand asset for indie dev and AI-driven environments. Discipline: Game Design & Interactive Media (ambient audio curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Standard audio licensing is rigid; x402 allows for 'Ambient-as-a-Service' where the music budget scales exactly with the player's world interactions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VibeStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-immersive-narrative-nodes-21-x402 Title: PlotTwist · x402 Theme: Game Design & Interactive Media (games) · branching story data Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless narrative engine where every story fork is a paid transaction. Authors deploy branching paths as specific data nodes; players spend 0.01 USDC to 'choose' their path, unlocking the next sequence of metadata while directly compensating the writer. High-stakes storytelling where every choice has a literal cost. Why Hedera: Current branching narratives are limited by platform silos. x402 allows for a global, cross-game story graph where narrative state is owned by the player's wallet and every interaction is a micro-settlement for the creator. Market: TAM $22B — The global gaming narrative and procedural content generation market. | SAM $850M — The interactive fiction and RPG DLC market pivoting to micro-transactional narrative consumption. | SOM $12M — Indie RPG developers and 'Choose Your Own Adventure' web3 creators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PlotTwist" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless narrative engine where every story fork is a paid transaction. Authors deploy branching paths as specific data nodes; players spend 0.01 USDC to 'choose' their path, unlocking the next sequence of metadata while directly compensating the writer. High-stakes storytelling where every choice has a literal cost. Discipline: Game Design & Interactive Media (branching story data). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current branching narratives are limited by platform silos. x402 allows for a global, cross-game story graph where narrative state is owned by the player's wallet and every interaction is a micro-settlement for the creator. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PlotTwist" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-xr-artifact-library-22-x402 Title: Relic · x402 Theme: Game Design & Interactive Media (games) · 3D object preservation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity spatial asset vault where every inspection, download, or integration is metered. Users pay 0.01 USDC to unlock an encrypted 3D artifact from IPFS. Developers pay per-call to pull assets into their own XR scenes. Payment serves as the digital preservation tax, funding the pinning of history while providing instant provenance via Base transaction hashes. Why Hedera: Moving from 'free archive' to 'micropayment gateway' creates a sustainable preservation loop. By charging per-access, the library acts as a decentralized API for game engines, where the cost of 0.01 USDC ensures high-quality mesh delivery without heavy subscription overhead. Market: TAM $13.4B — The global 3D modeling and digital asset management market across AEC, gaming, and heritage. | SAM $450M — The growing market for XR-ready game assets, digital twins, and virtual museum plugins. | SOM $12M — Specialized preservationist collectives and indie XR devs building 'historical' levels on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Relic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity spatial asset vault where every inspection, download, or integration is metered. Users pay 0.01 USDC to unlock an encrypted 3D artifact from IPFS. Developers pay per-call to pull assets into their own XR scenes. Payment serves as the digital preservation tax, funding the pinning of history while providing instant provenance via Base transaction hashes. Discipline: Game Design & Interactive Media (3D object preservation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'free archive' to 'micropayment gateway' creates a sustainable preservation loop. By charging per-access, the library acts as a decentralized API for game engines, where the cost of 0.01 USDC ensures high-quality mesh delivery without heavy subscription overhead. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Relic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-game-jam-showcase-23-x402 Title: VAULT · x402 Theme: Game Design & Interactive Media (games) · project preservation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity digital reliquary for the indie underground. Game jam creators lock their build assets and metadata into immutable storage using x402-native micro-bounties. Fans pay $0.01 per 'Play' or 'Download' to trigger a direct-to-creator settlement, ensuring projects stay alive and creators get paid per interaction long after the jam countdown ends. No subscriptions, just a meter on the culture. Why Hedera: Preservation is usually a cost center; x402 turns it into a revenue stream. By metering access at the sub-penny level, we solve the 'digital rot' problem while providing a friction-less tip jar that functions as a gate for high-bandwidth assets (builds/SFX packs). Market: TAM $1.2B — The total addressable market for long-tail digital asset storage, itch.io enthusiasts, and the retro-gaming preservation economy. | SAM $85M — Indie game developers, jam participants (Global Game Jam, Ludum Dare), and asset flippers on Hedera. | SOM $4.2M — The 90,000+ yearly participants of major decentralized and global game jams seeking permanent portfolio hosting. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VAULT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity digital reliquary for the indie underground. Game jam creators lock their build assets and metadata into immutable storage using x402-native micro-bounties. Fans pay $0.01 per 'Play' or 'Download' to trigger a direct-to-creator settlement, ensuring projects stay alive and creators get paid per interaction long after the jam countdown ends. No subscriptions, just a meter on the culture. Discipline: Game Design & Interactive Media (project preservation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Preservation is usually a cost center; x402 turns it into a revenue stream. By metering access at the sub-penny level, we solve the 'digital rot' problem while providing a friction-less tip jar that functions as a gate for high-bandwidth assets (builds/SFX packs). 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VAULT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-tokenized-lore-boards-24-x402 Title: CanonCast · x402 Theme: Game Design & Interactive Media (games) · community content curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Collaborative world-building gated by micropayments. Every lore entry, plot branch, or character profile requires a 0.01 USDC unlock to view or a deposit to draft. Writers earn real-time streaming royalties as their canon is referenced or expanded upon by the community. No subscriptions, just a pay-per-read ledger for persistent storytelling. Why Hedera: By moving lore curation onto x402, we turn passive readers into micro-investors and creators into micro-earners. Each 'canonization' event is a settlement on Hedera, ensuring that the most valuable community contributions are economically weighted and permanently etched. Market: TAM $150B - The Total Addressable Market for digital interactive media and IP development. | SAM $4.2B - The global fan-fiction and collaborative writing market looking for better monetization primitives. | SOM $25M - On-chain RPG communities and DAO-led game universes on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CanonCast" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Collaborative world-building gated by micropayments. Every lore entry, plot branch, or character profile requires a 0.01 USDC unlock to view or a deposit to draft. Writers earn real-time streaming royalties as their canon is referenced or expanded upon by the community. No subscriptions, just a pay-per-read ledger for persistent storytelling. Discipline: Game Design & Interactive Media (community content curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving lore curation onto x402, we turn passive readers into micro-investors and creators into micro-earners. Each 'canonization' event is a settlement on Hedera, ensuring that the most valuable community contributions are economically weighted and permanently etched. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CanonCast" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-gasless-guilds-0-x402 Title: Strike · x402 Theme: Game Design & Interactive Media (games) · multiplayer coordination Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless coordination layer where every guild action—joining, voting, or resource pooling—is a 0.01 USDC micro-transaction. By removing the friction of gas and traditional signatures, 'Strike' turns guild management into a high-velocity, real-time tactical stream. Pay per command to coordinate raids or settle loot in sub-second intervals, powered by the embedded wallet-signed HTS transfer permits. Why Hedera: Shifting from 'gasless' (vague value) to 'micropayment-native' (direct value) creates a sustainable incentive loop for guild leaders and developers. In competitive gaming, the 0.01 USDC fee acts as a spam filter and a commitment signal, ensuring every coordination event is intentional and settled on-chain without the UX hurdle of gas tokens. Market: TAM $4.2B — The global virtual goods and coordination layer market for multiplayer environments. | SAM $850M — The addressable market for competitive indie gaming guilds and DAO-integrated mobile e-sports. | SOM $12M — Initial user spend within high-throughput on-chain strategy games and 'Farcaster-native' gaming tribes. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Strike" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless coordination layer where every guild action—joining, voting, or resource pooling—is a 0.01 USDC micro-transaction. By removing the friction of gas and traditional signatures, 'Strike' turns guild management into a high-velocity, real-time tactical stream. Pay per command to coordinate raids or settle loot in sub-second intervals, powered by the embedded wallet-signed HTS transfer permits. Discipline: Game Design & Interactive Media (multiplayer coordination). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from 'gasless' (vague value) to 'micropayment-native' (direct value) creates a sustainable incentive loop for guild leaders and developers. In competitive gaming, the 0.01 USDC fee acts as a spam filter and a commitment signal, ensuring every coordination event is intentional and settled on-chain without the UX hurdle of gas tokens. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Strike" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-sponsored-loot-drops-1-x402 Title: LOOTFLOW · x402 Theme: Game Design & Interactive Media (games) · reward distribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Gaming brands and creators deposit prize pools that players claim via HTS transfer signatures. Every 'claim' call triggers a 0.01 USDC facilitation fee, turning reward distribution into a sustainable revenue model for the platform rather than a cost center. Players pay $0.01 to unlock a random drop, bypassing traditional gas fees while the facilitator handles the Base settlement. Why Hedera: By shifting from 'free' to a micro-payment 'unlock' fee, the platform solves the 'free-rider' problem of bot-farming rewards. The $0.01 fee acts as a Sybil-resistance layer and a monetization engine for the distribution infrastructure itself. Market: TAM $2.2B — Global gaming reward and loyalty program market. | SAM $140M — Projected spending on digital loot boxes and in-game microtransaction fees. | SOM $8.5M — Distribution fees for indie RPGs and Web3 hyper-casual games on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LOOTFLOW" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Gaming brands and creators deposit prize pools that players claim via HTS transfer signatures. Every 'claim' call triggers a 0.01 USDC facilitation fee, turning reward distribution into a sustainable revenue model for the platform rather than a cost center. Players pay $0.01 to unlock a random drop, bypassing traditional gas fees while the facilitator handles the Base settlement. Discipline: Game Design & Interactive Media (reward distribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'free' to a micro-payment 'unlock' fee, the platform solves the 'free-rider' problem of bot-farming rewards. The $0.01 fee acts as a Sybil-resistance layer and a monetization engine for the distribution infrastructure itself. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LOOTFLOW" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-privy-vr-lobby-2-x402 Title: Sphere · x402 Theme: Game Design & Interactive Media (games) · XR social spaces Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Spatial social hubs where interaction is metered by proximity. 0.01 USDC unlocks 5 minutes of high-fidelity voice spatialization or private 'whisper zones' within VR environments. No subscriptions; users pay-per-presence via signature, enabling hyper-fluid pop-up events and sovereign social gating without the friction of pre-funding gas. Why Hedera: XR social spaces suffer from 'the tragedy of the commons' or rigid subscriptions. x402 introduces a micro-tax on presence that funds the spatial compute and high-bandwidth assets in real-time. It turns every 'lobby' into a sovereign economical zone where creators are paid per attendee-minute. Market: TAM $15B — The global Metaverse and Virtual Social Space market transitioning to decentralized, micro-economy models. | SAM $400M — Focused on social XR platforms and virtual event organizers seeking granular monetization via Base. | SOM $12M — Early-stage XR developers and 'metaverse' event producers utilizing HashPack for frictionless onboarding. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sphere" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Spatial social hubs where interaction is metered by proximity. 0.01 USDC unlocks 5 minutes of high-fidelity voice spatialization or private 'whisper zones' within VR environments. No subscriptions; users pay-per-presence via signature, enabling hyper-fluid pop-up events and sovereign social gating without the friction of pre-funding gas. Discipline: Game Design & Interactive Media (XR social spaces). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: XR social spaces suffer from 'the tragedy of the commons' or rigid subscriptions. x402 introduces a micro-tax on presence that funds the spatial compute and high-bandwidth assets in real-time. It turns every 'lobby' into a sovereign economical zone where creators are paid per attendee-minute. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sphere" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-onchain-avatar-store-3-x402 Title: FABRIC · x402 Theme: Game Design & Interactive Media (games) · digital fashion Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-fit. A curated terminal for digital identity where every texture, mesh, and accessory is metered. No bulk purchases or subscriptions; use HTS transfer to pull USDC for every garment 'try-on' and 'save' directly from your Magic Link email sign-in. Facilitators settle high-frequency micro-styling choices into a single Hedera transaction id, enabling a frictionless high-fashion wardrobe for the agentic metaverse. Why Hedera: By gating the 'Save' and 'Render' functions behind x402, digital fashion shifts from static assets to a dynamic, metered service. This reflects the high-churn nature of digital identity where users want to change looks frequently without committing to high-priced NFTs. Market: TAM $2.8B — The global virtual goods and skins market, pivoting toward micro-transactional utility and AI-agent styling. | SAM $140M — Metered identity for the 20M+ active users in web3 social and gaming ecosystems. | SOM $8.5M — Pay-per-style unlocks for Base-native avatar platforms and Farcaster frames. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FABRIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-fit. A curated terminal for digital identity where every texture, mesh, and accessory is metered. No bulk purchases or subscriptions; use HTS transfer to pull USDC for every garment 'try-on' and 'save' directly from your Magic Link email sign-in. Facilitators settle high-frequency micro-styling choices into a single Hedera transaction id, enabling a frictionless high-fashion wardrobe for the agentic metaverse. Discipline: Game Design & Interactive Media (digital fashion). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By gating the 'Save' and 'Render' functions behind x402, digital fashion shifts from static assets to a dynamic, metered service. This reflects the high-churn nature of digital identity where users want to change looks frequently without committing to high-priced NFTs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FABRIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-social-quest-chains-4-x402 Title: LoreLine · x402 Theme: Game Design & Interactive Media (games) · interactive storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Interactive RPG adventures where every narrative choice or path branch requires an x402 micro-settlement. Users don't just read; they pay-to-play through high-stakes quest hierarchies where micro-payments (0.01 USDC) trigger state changes, unlock gated lore, or fund the 'bounty' for the next contributor in the chain. Collective storytelling with skin in the game. Why Hedera: By moving away from 'gasless' (which implies zero-cost) to 'x402-native' (pay-per-intent), the storytelling becomes a sustainable economy. The frictionless HTS transfer auth ensures the flow of the story isn't broken by popups, while the micropayment adds weight to every plot decision. Market: TAM $21B — Global interactive fiction and creator-led transmedia market. | SAM $1.4B — The projected market for 'Onchain Games' and decentralized narrative platforms. | SOM $12M — Target capture of high-frequency social fiction readers and Base-native RPG communities. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LoreLine" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Interactive RPG adventures where every narrative choice or path branch requires an x402 micro-settlement. Users don't just read; they pay-to-play through high-stakes quest hierarchies where micro-payments (0.01 USDC) trigger state changes, unlock gated lore, or fund the 'bounty' for the next contributor in the chain. Collective storytelling with skin in the game. Discipline: Game Design & Interactive Media (interactive storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving away from 'gasless' (which implies zero-cost) to 'x402-native' (pay-per-intent), the storytelling becomes a sustainable economy. The frictionless HTS transfer auth ensures the flow of the story isn't broken by popups, while the micropayment adds weight to every plot decision. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LoreLine" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-gasless-xr-art-swap-5-x402 Title: VOMEL · x402 Theme: Game Design & Interactive Media (games) · interactive art exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-liquid digital gallery where every 'view' is an on-chain acquisition. Instead of traditional trading, users stream 0.01 USDC per frame or interaction to unlock volumetric XR assets. Artists receive real-time, micro-settlements as their immersive art is experienced, turning interactive media into a metered utility. Why Hedera: By moving from 'Hedera's fixed sub-cent fees' to 'pay-per-interaction,' we eliminate the friction of high price tags while ensuring creators are paid for every second of engagement. x402 allows for granular monetization of high-fidelity XR data. Market: TAM $150B — The global XR and metaverse hardware/software market shifting toward micro-consumption models. | SAM $2.8B — The niche for high-end digital art collectibles and programmable interactive media. | SOM $45M — Active Base ecosystem creators and XR enthusiasts using embedded wallets for frictionless media consumption. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VOMEL" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-liquid digital gallery where every 'view' is an on-chain acquisition. Instead of traditional trading, users stream 0.01 USDC per frame or interaction to unlock volumetric XR assets. Artists receive real-time, micro-settlements as their immersive art is experienced, turning interactive media into a metered utility. Discipline: Game Design & Interactive Media (interactive art exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'Hedera's fixed sub-cent fees' to 'pay-per-interaction,' we eliminate the friction of high price tags while ensuring creators are paid for every second of engagement. x402 allows for granular monetization of high-fidelity XR data. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VOMEL" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-privy-game-jam-6-x402 Title: Sprint · x402 Theme: Game Design & Interactive Media (games) · developer community Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A playground for hyper-casual dev loops. $0.01 USDC grants a 10-minute 'Hot Reload' window where every build and playtest transaction is instantaneous and gas-free via the embedded wallet. Use micropayments to vote on specific pull requests during the jam or to tip a 'code-review' agent to audit your logic in real-time. Finalists are ranked by their 'Total Economic Throughput'—the amount of micropayments their demo generated during the judging period. Why Hedera: Reframes a game jam from a one-off event to a persistent, metered development sandbox. The x402 primitive turns participation from 'free/subsidized' to 'micropayment-powered,' filtering for quality while rewarding live feedback loops. Market: TAM $2.1B — The global Game Jam and Developer Education market as it transitions to decentralized, automated reward structures. | SAM $450M — The interactive media dev-tooling market, specifically targeting the shift toward micro-transactional playtesting and 'Earn-as-you-Build' hackathons. | SOM $12M — The niche of EVM-based game developers and HashPack-integrated indie studios utilizing Base for low-cost state updates. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sprint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A playground for hyper-casual dev loops. $0.01 USDC grants a 10-minute 'Hot Reload' window where every build and playtest transaction is instantaneous and gas-free via the embedded wallet. Use micropayments to vote on specific pull requests during the jam or to tip a 'code-review' agent to audit your logic in real-time. Finalists are ranked by their 'Total Economic Throughput'—the amount of micropayments their demo generated during the judging period. Discipline: Game Design & Interactive Media (developer community). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Reframes a game jam from a one-off event to a persistent, metered development sandbox. The x402 primitive turns participation from 'free/subsidized' to 'micropayment-powered,' filtering for quality while rewarding live feedback loops. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sprint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-sponsored-beta-access-7-x402 Title: Playtest · x402 Theme: Game Design & Interactive Media (games) · user testing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn user testing into a high-fidelity proof-of-work economy. Testers pay 0.01 USDC to unlock gated 'Pre-Alpha' builds, proving skin-in-the-game while earning reputation. Developers use x402 to meter feature-specific feedback loops, ensuring every bug report or sessions is backed by a verified, paid interaction. No more spam testers; only committed players. Why Hedera: Traditional beta testing suffers from low-quality feedback and ghosting. By moving from 'free' to a micro-transactional 'pay-to-access' model, we filter for high-intent testers. The x402 protocol ensures the transaction is frictionless via the embedded wallet, while the 0.01 USDC cost acts as a sybil-resistance mechanism for valuable early-stage data. Market: TAM $15.4B — The global game testing and quality assurance market shifting toward crowdsourced models. | SAM $850M — The addressable market for decentralized autonomous gaming and micro-incentivized QA. | SOM $12M — Web3-native game studios on Hedera requiring high-fidelity user data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Playtest" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn user testing into a high-fidelity proof-of-work economy. Testers pay 0.01 USDC to unlock gated 'Pre-Alpha' builds, proving skin-in-the-game while earning reputation. Developers use x402 to meter feature-specific feedback loops, ensuring every bug report or sessions is backed by a verified, paid interaction. No more spam testers; only committed players. Discipline: Game Design & Interactive Media (user testing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional beta testing suffers from low-quality feedback and ghosting. By moving from 'free' to a micro-transactional 'pay-to-access' model, we filter for high-intent testers. The x402 protocol ensures the transaction is frictionless via the embedded wallet, while the 0.01 USDC cost acts as a sybil-resistance mechanism for valuable early-stage data. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Playtest" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-privy-scoreboard-8-x402 Title: GLHF · x402 Theme: Game Design & Interactive Media (games) · competitive ranking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-competitive leaderboard where every rank movement is a financial stake. Players sign to authorize 0.01 USDC to 'challenge' a slot or update their high score. It turns passive ranking into a high-velocity, real-time betting floor where skill and hardware-signing speed determine the alpha. Why Hedera: By shifting from gas-free (subsidized) to x402-native (micromonetized), we filter for intent and create a 'sink' for surplus USDC. Each score update becomes a low-friction micro-transaction, transforming the scoreboard into a viable revenue stream for game devs and a high-stakes environment for players. Market: TAM $180B — Global video game software market and competitive social media platforms. | SAM $4.2B — Competitive mobile gaming and casual e-sports tournament fees. | SOM $85M — On-chain gaming ecosystems on Hedera utilizing HTS transfer for friction-free skill-based wagering. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GLHF" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-competitive leaderboard where every rank movement is a financial stake. Players sign to authorize 0.01 USDC to 'challenge' a slot or update their high score. It turns passive ranking into a high-velocity, real-time betting floor where skill and hardware-signing speed determine the alpha. Discipline: Game Design & Interactive Media (competitive ranking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from gas-free (subsidized) to x402-native (micromonetized), we filter for intent and create a 'sink' for surplus USDC. Each score update becomes a low-friction micro-transaction, transforming the scoreboard into a viable revenue stream for game devs and a high-stakes environment for players. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GLHF" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-instant-nft-trades-9-x402 Title: QuickSwap · x402 Theme: Game Design & Interactive Media (games) · marketplace integration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-frictionless marketplace for digital asset swaps where every trade execution, pricing bridge, and asset preview is gated by a 0.01 USDC micro-settlement. Eliminate high-fee gatekeeping by metering the 'intent to trade' rather than taxing the volume. Users sign HTS transfer permits for instant liquidity access, paying only for the compute and routing used in the instant swap. Why Hedera: By turning the trade execution into a pay-per-use primitive, we replace chunky gas overhead and exchange spreads with a transparent, per-call service fee of 0.01 USDC. This allows for high-frequency micro-trading of in-game items that would otherwise be economically unviable. Market: TAM $190B — Global video game commerce and digital item marketplace economy. | SAM $4.2B — In-game asset trading and secondary skin markets on Layer-2 networks. | SOM $85M — Micro-transaction volume for mid-core web3 mobile games using HashPack-based onboarding. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "QuickSwap" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-frictionless marketplace for digital asset swaps where every trade execution, pricing bridge, and asset preview is gated by a 0.01 USDC micro-settlement. Eliminate high-fee gatekeeping by metering the 'intent to trade' rather than taxing the volume. Users sign HTS transfer permits for instant liquidity access, paying only for the compute and routing used in the instant swap. Discipline: Game Design & Interactive Media (marketplace integration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the trade execution into a pay-per-use primitive, we replace chunky gas overhead and exchange spreads with a transparent, per-call service fee of 0.01 USDC. This allows for high-frequency micro-trading of in-game items that would otherwise be economically unviable. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "QuickSwap" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-gasless-avatar-gifting-10-x402 Title: SKIN DROP · x402 Theme: Game Design & Interactive Media (games) · social gifting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A peer-to-peer wardrobe protocol where avatar assets are micro-gated. Instead of abstract gifting, users sign an HTS transfer permit to 'Drop' a cosmetic directly onto a friend's profile. Each unlock costs 0.01 USDC, instantly settling the creator royalty and the transfer fee in a single signature. It turns social gifting into a high-frequency, friction-free micro-economy for digital identity. Why Hedera: Removes the 'gasless' abstraction (which usually implies a subsidy) and replaces it with a sustainable 0.01 USDC payment primitive. This makes every social interaction a direct economic settlement between the giver, the creator, and the protocol. Market: TAM $14B — The global virtual goods and social gifting market within metaverse environments. | SAM $450M — The emerging 'social-fi' avatar cosmetic and skin market on Layer 2 networks. | SOM $12M — Initial volume from influencer-led 'limited drop' events and cross-game cosmetic gifting. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SKIN DROP" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A peer-to-peer wardrobe protocol where avatar assets are micro-gated. Instead of abstract gifting, users sign an HTS transfer permit to 'Drop' a cosmetic directly onto a friend's profile. Each unlock costs 0.01 USDC, instantly settling the creator royalty and the transfer fee in a single signature. It turns social gifting into a high-frequency, friction-free micro-economy for digital identity. Discipline: Game Design & Interactive Media (social gifting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Removes the 'gasless' abstraction (which usually implies a subsidy) and replaces it with a sustainable 0.01 USDC payment primitive. This makes every social interaction a direct economic settlement between the giver, the creator, and the protocol. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SKIN DROP" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-sponsored-skill-boosts-11-x402 Title: Twitch-Reflex Perk · x402 Theme: Game Design & Interactive Media (games) · in-game perks Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Invert the F2P grind by replacing predatory loot boxes with deterministic 0.01 USDC micro-transactions. Players authorize single-use, off-chain HTS transfer signatures to instant-trigger server-side buffs (e.g., +10% Speed, Fog of War lift, or instant respawn). One click, one cent, no gas pop-ups — seamless high-frequency monetization for competitive loops. Why Hedera: Traditional gas fees kill the flow of high-intensity gaming. x402 allows for 'invisible' payments where the player signs a permission once and the game triggers one-cent debits per perk, creating a new 'pay-to-survive' arcade economy that scales to millions of micro-tx. Market: TAM $92B — The global In-Game Purchase market, transitioning from lump-sum packs to granular, usage-based powerups. | SAM $850M — The addressable market for 'hyper-casual' and web-based competitive games adopting Base for near-instant settlement. | SOM $12M — Initial capture within the Hedera testnet indie dev ecosystem and competitive gaming DAOs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Twitch-Reflex Perk" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Invert the F2P grind by replacing predatory loot boxes with deterministic 0.01 USDC micro-transactions. Players authorize single-use, off-chain HTS transfer signatures to instant-trigger server-side buffs (e.g., +10% Speed, Fog of War lift, or instant respawn). One click, one cent, no gas pop-ups — seamless high-frequency monetization for competitive loops. Discipline: Game Design & Interactive Media (in-game perks). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional gas fees kill the flow of high-intensity gaming. x402 allows for 'invisible' payments where the player signs a permission once and the game triggers one-cent debits per perk, creating a new 'pay-to-survive' arcade economy that scales to millions of micro-tx. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Twitch-Reflex Perk" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-privy-story-worlds-12-x402 Title: LOREGATE · x402 Theme: Game Design & Interactive Media (games) · user-generated content Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered narrative engine where every story branch is an x402-gated asset. Instead of 'gasless' abstraction, players pay $0.01 USDC to mint a permanent choice into the world's canon or unlock a creator's secret lore path. Authors earn instant HTS transfer settlements as their worlds are traversed by humans and AI agents seeking structured narrative data. Payment is the literal 'Turn of the Page.' Why Hedera: Shifts from a sponsored/passive model to a hyper-active micropayment economy. The HTS transfer signature turns reading into a series of micro-transactions, creating a direct value link between the writer's creativity and the reader's progression. Market: TAM $140B — The global User-Generated Content (UGC) and gaming market. | SAM $450M — The emerging 'Agentic Entertainment' market where autonomous agents consume and generate interactive lore. | SOM $12M — Indie RPG developers and web3 fiction communities on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LOREGATE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered narrative engine where every story branch is an x402-gated asset. Instead of 'gasless' abstraction, players pay $0.01 USDC to mint a permanent choice into the world's canon or unlock a creator's secret lore path. Authors earn instant HTS transfer settlements as their worlds are traversed by humans and AI agents seeking structured narrative data. Payment is the literal 'Turn of the Page.' Discipline: Game Design & Interactive Media (user-generated content). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from a sponsored/passive model to a hyper-active micropayment economy. The HTS transfer signature turns reading into a series of micro-transactions, creating a direct value link between the writer's creativity and the reader's progression. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LOREGATE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-gas-free-co-op-play-13-x402 Title: PAYLOAD · x402 Theme: Game Design & Interactive Media (games) · multiplayer mechanics Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-input. A hyper-casual multiplayer engine where every move, spell cast, or strategic toggle is a micropayment. Eliminate 'free-to-play' friction with 'pay-to-play' precision: players sign an HTS transfer authorization to stream USDC per game tick. Facilitators settle batch moves on Hedera, turning the global leaderboard into a literal real-time revenue stream where winning matches redistributes the pool. Why Hedera: Replacing 'gasless' (overhead) with 'micro-paid' (revenue). By pricing inputs at $0.01, the game shifts from a subsidized expense to a self-sustaining economy where the cost of compute and state-sync is covered by the player in real-time. Market: TAM $25B — The global 'pay-per-session' and arcade-style gaming market transitioned to autonomous agent-compatible infra. | SAM $850M — The on-chain gaming and micro-transaction sector within EVM ecosystems. | SOM $12M — Initial capture of competitive hyper-casual gamers on Hedera and Farcaster frames. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PAYLOAD" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-input. A hyper-casual multiplayer engine where every move, spell cast, or strategic toggle is a micropayment. Eliminate 'free-to-play' friction with 'pay-to-play' precision: players sign an HTS transfer authorization to stream USDC per game tick. Facilitators settle batch moves on Hedera, turning the global leaderboard into a literal real-time revenue stream where winning matches redistributes the pool. Discipline: Game Design & Interactive Media (multiplayer mechanics). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Replacing 'gasless' (overhead) with 'micro-paid' (revenue). By pricing inputs at $0.01, the game shifts from a subsidized expense to a self-sustaining economy where the cost of compute and state-sync is covered by the player in real-time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PAYLOAD" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-sponsored-xr-avatars-14-x402 Title: SponsorMask · x402 Theme: Game Design & Interactive Media (games) · virtual identity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Wearable sponsorship as a revenue stream. Avatars fetch high-fidelity, branded assets via x402-metered streams. Each time a user 'equips' or 'activates' a sponsored emote or skin in a virtual space, a 0.01 USDC micropayment is settled to the creator, funded by the brand's deposit or the user's interaction. Identity becomes a high-frequency transactional surface. Why Hedera: By moving from bulk licensing to x402-native micro-utilization, brands only pay for active impressions and users earn for active engagement. It eliminates the friction of upfront NFT purchases for temporary virtual events. Market: TAM $54B — The global digital avatar and virtual goods market transitioning to on-chain provenance. | SAM $4.2B — Projected spend on virtual goods and identities in decentralized XR environments. | SOM $15M — Reaching early adopters in Base-native social hubs (Warpcast/Farcaster) using embedded HashPack wallets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SponsorMask" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Wearable sponsorship as a revenue stream. Avatars fetch high-fidelity, branded assets via x402-metered streams. Each time a user 'equips' or 'activates' a sponsored emote or skin in a virtual space, a 0.01 USDC micropayment is settled to the creator, funded by the brand's deposit or the user's interaction. Identity becomes a high-frequency transactional surface. Discipline: Game Design & Interactive Media (virtual identity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from bulk licensing to x402-native micro-utilization, brands only pay for active impressions and users earn for active engagement. It eliminates the friction of upfront NFT purchases for temporary virtual events. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SponsorMask" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-privy-puzzle-rewards-15-x402 Title: Sovereign Solitaire · x402 Theme: Game Design & Interactive Media (games) · casual game incentives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-casual puzzle engine where every hint, level skip, and 'Solution Reveal' is a discrete 0.01 USDC x402 transaction. No ads, no 'gems'—just raw micropayment-gated gameplay. High-score leaderboards are verified by the settlement hash, turning casual play into a high-stakes, pay-to-progress competitive arena optimized for AI-agent solvers and human speedrunners. Why Hedera: By removing abstract virtual currencies and replacing them with HTS transfer signed micropayments, the game eliminates friction for global players. The x402 model ensures the developer is paid instantly per interaction, making 'free-to-play' economics obsolete in favor of true utility-based gaming. Market: TAM $18B — The global casual puzzle game and micro-transaction market transitioning to web3 rails. | SAM $850M — The casual mobile gaming market shift toward direct-to-developer micro-transactions and ad-free models. | SOM $12M — On-chain puzzle enthusiasts and autonomous agents on Hedera testnet using automated wallet signing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sovereign Solitaire" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-casual puzzle engine where every hint, level skip, and 'Solution Reveal' is a discrete 0.01 USDC x402 transaction. No ads, no 'gems'—just raw micropayment-gated gameplay. High-score leaderboards are verified by the settlement hash, turning casual play into a high-stakes, pay-to-progress competitive arena optimized for AI-agent solvers and human speedrunners. Discipline: Game Design & Interactive Media (casual game incentives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By removing abstract virtual currencies and replacing them with HTS transfer signed micropayments, the game eliminates friction for global players. The x402 model ensures the developer is paid instantly per interaction, making 'free-to-play' economics obsolete in favor of true utility-based gaming. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sovereign Solitaire" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-gasless-avatar-battles-16-x402 Title: SLAY · x402 Theme: Game Design & Interactive Media (games) · competitive avatars Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Staking skins in the arena. Every strike, dodge, and taunt is an x402-metered micro-transaction. No 'sponsored' play—players pay 0.01 USDC per match-move to the protocol, with winners taking the pot. Pay-per-swing mechanics turn competitive avatars into high-stakes economic agents. Why Hedera: Moving from 'gasless/sponsored' to 'intentional micropayments' changes the game loop from passive consumption to high-intent competitive staking. x402 allows for granular, sub-penny combat logic that settles instantly on Hedera. Market: TAM $190B — The global video game market shifting toward asset ownership and micro-wagers. | SAM $3.2B — Competitive mobile gaming and 'play-to-earn' micro-transaction segments. | SOM $18M — High-frequency competitive avatar battles on Hedera using HashPack-integrated wallets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SLAY" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Staking skins in the arena. Every strike, dodge, and taunt is an x402-metered micro-transaction. No 'sponsored' play—players pay 0.01 USDC per match-move to the protocol, with winners taking the pot. Pay-per-swing mechanics turn competitive avatars into high-stakes economic agents. Discipline: Game Design & Interactive Media (competitive avatars). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'gasless/sponsored' to 'intentional micropayments' changes the game loop from passive consumption to high-intent competitive staking. x402 allows for granular, sub-penny combat logic that settles instantly on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SLAY" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-sponsored-xr-exhibits-17-x402 Title: LUX · x402 Theme: Game Design & Interactive Media (games) · virtual galleries Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An immersive XR gallery engine where every gaze-trigger or deep-dive interaction costs $0.01. Viewers pay-per-curation to unlock high-fidelity 3D assets, while creators receive instant USDC settlements. Traditional 'sponsors' are replaced by automated micropayment streams that fund visitor exploration in real-time. Why Hedera: Instead of passive viewing or complex ad-models, the 'pay-per-look' model ensures every high-bandwidth asset is monetized at the point of interaction, shifting the cost from the host to the micro-consumer or their automated sponsor. Market: TAM $18B — The total addressable market for the global Metaverse & Digital Twin interaction economy. | SAM $450M — Virtual art sales and 3D digital collectible secondary markets moving toward micropayment models. | SOM $12M — Early-adopter XR artists and niche digital fashion brands using Base for micro-exhibition access. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LUX" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An immersive XR gallery engine where every gaze-trigger or deep-dive interaction costs $0.01. Viewers pay-per-curation to unlock high-fidelity 3D assets, while creators receive instant USDC settlements. Traditional 'sponsors' are replaced by automated micropayment streams that fund visitor exploration in real-time. Discipline: Game Design & Interactive Media (virtual galleries). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Instead of passive viewing or complex ad-models, the 'pay-per-look' model ensures every high-bandwidth asset is monetized at the point of interaction, shifting the cost from the host to the micro-consumer or their automated sponsor. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LUX" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-privy-crafting-market-18-x402 Title: CraftMelt · x402 Theme: Game Design & Interactive Media (games) · player economies Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-craft loot protocol. Every recipe execution triggers an x402 micropayment to the designer, turning in-game crafting into a high-velocity revenue engine via HTS transfer signed authorizations. Why Hedera: By shifting from 'sponsored trades' to 'metered crafting,' the game economy becomes a sustainable marketplace where designers are paid per use of their blueprints, settled instantly on Hedera. Market: TAM $220B — Total addressable market for global in-game asset sales and player-driven economies. | SAM $1.2B — Indie game economies implementing designer-royalties and metered item creation. | SOM $85M — Micro-transaction volume for on-chain crafting systems and procedural item games. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CraftMelt" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-craft loot protocol. Every recipe execution triggers an x402 micropayment to the designer, turning in-game crafting into a high-velocity revenue engine via HTS transfer signed authorizations. Discipline: Game Design & Interactive Media (player economies). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'sponsored trades' to 'metered crafting,' the game economy becomes a sustainable marketplace where designers are paid per use of their blueprints, settled instantly on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CraftMelt" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-gasless-streaming-rewards-19-x402 Title: RAIDPAD · x402 Theme: Game Design & Interactive Media (games) · viewer incentives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A streaming overlay that flips the reward model: viewers pay 0.01 USDC to 'Proof-of-Cheer' in real-time. These micro-payments flow directly to the streamer's signature-authorized wallet, bypassing platform cuts. In exchange, the stream metadata triggers instant x402-gated interactive events (boss spawns, map changes, weapon drops) visible to all, but authored by the payer. Why Hedera: Moving from 'gasless rewards' (a cost to the creator) to 'micro-transaction engagement' (a revenue stream). By using x402, the viewer authorizes a tiny payment via the embedded wallet that settled on Hedera, making every 'cheer' a sub-cent on-chain event that actually alters the game state. Market: TAM $250B — The global live-streaming and interactive entertainment market. | SAM $4.2B — The estimated revenue of the 'Bits' and micro-tipping economy on Twitch and YouTube Gaming. | SOM $85M — Targeting the niche of 'Crowd Control' gaming and interactive simulation streamers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RAIDPAD" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A streaming overlay that flips the reward model: viewers pay 0.01 USDC to 'Proof-of-Cheer' in real-time. These micro-payments flow directly to the streamer's signature-authorized wallet, bypassing platform cuts. In exchange, the stream metadata triggers instant x402-gated interactive events (boss spawns, map changes, weapon drops) visible to all, but authored by the payer. Discipline: Game Design & Interactive Media (viewer incentives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'gasless rewards' (a cost to the creator) to 'micro-transaction engagement' (a revenue stream). By using x402, the viewer authorizes a tiny payment via the embedded wallet that settled on Hedera, making every 'cheer' a sub-cent on-chain event that actually alters the game state. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RAIDPAD" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-sponsored-fan-tokens-20-x402 Title: HyperFan · x402 Theme: Game Design & Interactive Media (games) · community engagement Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Eliminate speculative friction. Reframe fan loyalty as a direct micro-transactional economy. Fans pay 0.01 USDC to 'Boost' their player in real-time, trigger on-screen alerts, or vote on the next map. Instead of abstract tokens, every interaction is a settled payment. Creators get instant liquid revenue; fans get verifiable influence without the overhead of gas or token volatility. Payouts are metered per engagement event. Why Hedera: By replacing 'tokens' with x402 micropayments, we remove the complexity of DEX liquidity and price speculation. The value exchange is direct: $0.01 for 1 unit of influence. This converts passive viewers into active micropayers through the the embedded wallet sign-in flow. Market: TAM $8.2B — The global interactive live-streaming and fan engagement market transitioning to Web3. | SAM $420M — US-based live-streaming and creator-economy fans comfortable with digital micro-tipping. | SOM $12M — Early adopters in the Base gaming ecosystem and competitive 'Raid' communities. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "HyperFan" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Eliminate speculative friction. Reframe fan loyalty as a direct micro-transactional economy. Fans pay 0.01 USDC to 'Boost' their player in real-time, trigger on-screen alerts, or vote on the next map. Instead of abstract tokens, every interaction is a settled payment. Creators get instant liquid revenue; fans get verifiable influence without the overhead of gas or token volatility. Payouts are metered per engagement event. Discipline: Game Design & Interactive Media (community engagement). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing 'tokens' with x402 micropayments, we remove the complexity of DEX liquidity and price speculation. The value exchange is direct: $0.01 for 1 unit of influence. This converts passive viewers into active micropayers through the the embedded wallet sign-in flow. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "HyperFan" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-privy-challenge-leaderboards-21-x402 Title: Proof of Rank · x402 Theme: Game Design & Interactive Media (games) · competitive tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Convert game stats into competitive assets. Every leaderboard update, challenge submission, and rank-climb requires a 0.01 USDC micro-stake via HTS transfer. This transforms "vanilla" tracking into a high-stakes, pay-to-play arena where players pay to prove their worth, and top performers earn automated distributions from the pool. Why Hedera: By replacing gasless/sponsored models with x402, value is captured at the moment of competitive proof. It removes the 'free-rider' problem of leaderboards and ensures only committed players populate the top tiers, while creating a self-sustaining revenue stream for the game facilitator. Market: TAM $1.2B — Global competitive casual gaming and social leaderboard markets moving on-chain. | SAM $85M — On-chain gaming and competitive esports tracking platforms on Ethereum L2s. | SOM $4.2M — Individual challenge entrants and competitive clans on Hedera utilizing micropayment-gated rankings. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proof of Rank" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Convert game stats into competitive assets. Every leaderboard update, challenge submission, and rank-climb requires a 0.01 USDC micro-stake via HTS transfer. This transforms "vanilla" tracking into a high-stakes, pay-to-play arena where players pay to prove their worth, and top performers earn automated distributions from the pool. Discipline: Game Design & Interactive Media (competitive tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing gasless/sponsored models with x402, value is captured at the moment of competitive proof. It removes the 'free-rider' problem of leaderboards and ensures only committed players populate the top tiers, while creating a self-sustaining revenue stream for the game facilitator. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proof of Rank" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-gas-free-level-sharing-22-x402 Title: LEVELUP · x402 Theme: Game Design & Interactive Media (games) · user content Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An open inventory for custom game logic where creators earn $0.01 USDC every time a player loads their level. Bypass traditional storefront tax; players use the embedded wallet-signed HTS transfer auth to 'rent' a level session for a fraction of a cent. High-score data and level state are validated only upon payment, turning UGC into a micro-revenue stream for indie designers. Why Hedera: By shifting from 'gas-free' to 'micro-paid,' the friction moves from the network to the value exchange. Creators are incentivized to build quality content because each play-session is a direct, instant settlement on Hedera. Market: TAM $18B — The global In-Game Purchase and UGC market, currently dominated by high-fee centralized stores. | SAM $420M — The growing 'Pro-UGC' market across platforms like Roblox and Fortnite Creative, shifting toward direct sovereign monetization. | SOM $12M — Indie game developers on Hedera using x402 for serverless level hosting and creator-payouts. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LEVELUP" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An open inventory for custom game logic where creators earn $0.01 USDC every time a player loads their level. Bypass traditional storefront tax; players use the embedded wallet-signed HTS transfer auth to 'rent' a level session for a fraction of a cent. High-score data and level state are validated only upon payment, turning UGC into a micro-revenue stream for indie designers. Discipline: Game Design & Interactive Media (user content). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'gas-free' to 'micro-paid,' the friction moves from the network to the value exchange. Creators are incentivized to build quality content because each play-session is a direct, instant settlement on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LEVELUP" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-sponsored-multiplayer-drops-23-x402 Title: SlayerPay · x402 Theme: Game Design & Interactive Media (games) · event rewards Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Kill-to-earn logic for competitive gaming. Every loot box unlock or rare item drop requires a 0.01 USDC micro-settlement. Players sign a quick HTS transfer permit via the embedded wallet to claim, collateralizing the drop. This creates a high-velocity, real-stakes economy where the facilitator settles in-game events directly to the player's wallet via Base. Why Hedera: By moving from 'sponsored/free' to 'micro-paid,' rewards gain intrinsic market value and sybil-resistance. High-frequency gaming events are the perfect stress test for x402's low-latency micropayment architecture. Market: TAM $16B — Global digital in-game item and skins secondary market. | SAM $450M — Competitive gaming 'Battle Pass' and loot box revenue on EVM chains. | SOM $12M — On-chain FPS and Battle Royale rewards on Hedera within year one. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SlayerPay" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Kill-to-earn logic for competitive gaming. Every loot box unlock or rare item drop requires a 0.01 USDC micro-settlement. Players sign a quick HTS transfer permit via the embedded wallet to claim, collateralizing the drop. This creates a high-velocity, real-stakes economy where the facilitator settles in-game events directly to the player's wallet via Base. Discipline: Game Design & Interactive Media (event rewards). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'sponsored/free' to 'micro-paid,' rewards gain intrinsic market value and sybil-resistance. High-frequency gaming events are the perfect stress test for x402's low-latency micropayment architecture. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SlayerPay" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-privy-interactive-ads-24-x402 Title: FlashPoint · x402 Theme: Game Design & Interactive Media (games) · ad integration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-conversion interactive ad unit where brands pay users directly in USDC via x402 to clear friction. Instead of passive viewing, users sign a permit to 'Accept Interaction'—unlocking a gated playable experience or coupon for 0.01 USDC. The micro-reward is instantly settled to their Magic Link email sign-in, proving human engagement and intent-to-buy in a single on-chain transaction. Brands pay only for verified interactions, not impressions. Why Hedera: Traditional ads are noise; x402 turns them into micro-contracts. By making the reward/interaction a granular payment, we move from 'ad-gating' to 'ad-incentivizing' with zero gas barriers for the consumer. Market: TAM $680B - The total global digital advertising market shifting toward performance-based metrics. | SAM $4.2B - The global reward-based video and interactive mobile ad market. | SOM $95M - Web3 gaming discovery and dApp store ad placements using Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FlashPoint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-conversion interactive ad unit where brands pay users directly in USDC via x402 to clear friction. Instead of passive viewing, users sign a permit to 'Accept Interaction'—unlocking a gated playable experience or coupon for 0.01 USDC. The micro-reward is instantly settled to their Magic Link email sign-in, proving human engagement and intent-to-buy in a single on-chain transaction. Brands pay only for verified interactions, not impressions. Discipline: Game Design & Interactive Media (ad integration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional ads are noise; x402 turns them into micro-contracts. By making the reward/interaction a granular payment, we move from 'ad-gating' to 'ad-incentivizing' with zero gas barriers for the consumer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FlashPoint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-provenance-playbooks-0-x402 Title: InkPath · x402 Theme: Game Design & Interactive Media (games) · game narrative design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A narrative engine where every story branch is a logic gate. Players sign a $0.01 HTS transfer authorization to 'turn the page,' triggering a Base transaction that reveals the next plot point and permanently attributes the story path to their wallet. Writers earn micro-royalties every time a reader clicks a dialogue choice. Why Hedera: Traditional narrative games struggle with monetization outside of bulk buys. By metering the narrative via x402, we turn story consumption into a high-frequency, low-friction micro-transaction stream, ensuring authors are paid for every 'choice' made by the user. Market: TAM $18B — The global adventure and role-playing game market moving toward DLC-as-a-service. | SAM $480M — The interactive fiction and visual novel market segment. | SOM $12M — Indie narrative designers and web3 creators on Hedera using micropayments for serial storytelling. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkPath" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A narrative engine where every story branch is a logic gate. Players sign a $0.01 HTS transfer authorization to 'turn the page,' triggering a Base transaction that reveals the next plot point and permanently attributes the story path to their wallet. Writers earn micro-royalties every time a reader clicks a dialogue choice. Discipline: Game Design & Interactive Media (game narrative design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional narrative games struggle with monetization outside of bulk buys. By metering the narrative via x402, we turn story consumption into a high-frequency, low-friction micro-transaction stream, ensuring authors are paid for every 'choice' made by the user. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkPath" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-pixel-provenance-1-x402 Title: SpriteByte · x402 Theme: Game Design & Interactive Media (games) · pixel art creation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity pixel canvas where every stroke is a proof-of-humanity event. Pay 0.01 USDC to commit a 16x16 layer, ensuring high-stakes provenance for game assets. Developers license assets by paying creators per-view or per-export, bypassing bloated NFT minting fees for a granular, pay-as-you-draw attribution model. Why Hedera: By moving from 'NFT minting' to 'per-stroke' or 'per-export' micropayments, we eliminate friction for creators while creating a verifiable ledger of labor. x402 allows game engines to stream micropayments directly to artists as assets are loaded in-game. Market: TAM $2.8B — The global game asset and stock imagery market shifting toward atomic, verifiable ownership. | SAM $420M — Web3 game developers and indie asset marketplaces requiring verifiable on-chain history. | SOM $15M — Pixel-art enthusiasts and game jam participants on Hedera using HashPack-integrated workflows. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SpriteByte" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity pixel canvas where every stroke is a proof-of-humanity event. Pay 0.01 USDC to commit a 16x16 layer, ensuring high-stakes provenance for game assets. Developers license assets by paying creators per-view or per-export, bypassing bloated NFT minting fees for a granular, pay-as-you-draw attribution model. Discipline: Game Design & Interactive Media (pixel art creation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'NFT minting' to 'per-stroke' or 'per-export' micropayments, we eliminate friction for creators while creating a verifiable ledger of labor. x402 allows game engines to stream micropayments directly to artists as assets are loaded in-game. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SpriteByte" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-sound-slice-chain-2-x402 Title: StemStream · x402 Theme: Game Design & Interactive Media (games) · game audio sampling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular synthesis marketplace where game devs pay 0.01 USDC per high-fidelity stem pull. Sound designers earn instantly as their assets are 'sliced' and injected into real-time game engines. No subscriptions, just a micro-fee for every unique sonic texture used in a build. Why Hedera: Moving from static 'minting' to per-call 'extraction' turns every sound into a metered API. Integrating HTS transfer allows game engines to pull assets on-the-fly without friction-heavy wallet approvals, enabling dynamic, evolving soundtracks. Market: TAM $2.6B — The global game audio and interactive media licensing market. | SAM $850M — The middleware and sound asset market for indie and AA game studios. | SOM $12M — The niche of procedural audio and modular game soundtrack developers using Base/HashPack for asset management. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular synthesis marketplace where game devs pay 0.01 USDC per high-fidelity stem pull. Sound designers earn instantly as their assets are 'sliced' and injected into real-time game engines. No subscriptions, just a micro-fee for every unique sonic texture used in a build. Discipline: Game Design & Interactive Media (game audio sampling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from static 'minting' to per-call 'extraction' turns every sound into a metered API. Integrating HTS transfer allows game engines to pull assets on-the-fly without friction-heavy wallet approvals, enabling dynamic, evolving soundtracks. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-vr-provenance-hub-3-x402 Title: Loomscape · x402 Theme: Game Design & Interactive Media (games) · XR environment design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Architects and level designers deploy immersive environments as metered XR assets. Users pay 0.01 USDC per 'look-in' or spatial teleport to stream high-fidelity asset data. Every interaction validates provenance through a paid on-chain heartbeat, ensuring creators are compensated for every second of sub-discipline exploration rather than a one-time sale. Why Hedera: By shifting from NFT ownership to per-visit micropayments, we resolve the 'buy-and-forget' liquidity issue. The x402 model turns VR spaces into streaming revenue utilities for environment artists. Market: TAM $4.2B — The global Metaverse and 3D digital twin market moving toward agent-led spatial navigation. | SAM $120M — XR creators and spatial architects seeking per-entry monetization on Hedera. | SOM $8.5M — Early-adopter VR designers and indie game devs using metered asset streaming. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Loomscape" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Architects and level designers deploy immersive environments as metered XR assets. Users pay 0.01 USDC per 'look-in' or spatial teleport to stream high-fidelity asset data. Every interaction validates provenance through a paid on-chain heartbeat, ensuring creators are compensated for every second of sub-discipline exploration rather than a one-time sale. Discipline: Game Design & Interactive Media (XR environment design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from NFT ownership to per-visit micropayments, we resolve the 'buy-and-forget' liquidity issue. The x402 model turns VR spaces into streaming revenue utilities for environment artists. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Loomscape" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-avatar-dna-chain-4-x402 Title: TraitStream · x402 Theme: Game Design & Interactive Media (games) · custom avatar creation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A generative avatar engine where every limb, texture, and trait-roll costs exactly 0.01 USDC. Users 'spend' to evolve their DNA chain in real-time, effectively micro-funding the decentralized designers whose vector assets they utilize. No minting friction; just pay-per-mutation to build a unique identity. Why Hedera: By turning high-fidelity asset rendering into a 0.01 USDC utility call, we move away from 'all-or-nothing' speculative mints to a 'pay-as-you-design' meter. Every trait selection is a micro-settlement to the asset creator. Market: TAM $15.8B — Total addressable market for the global digital avatar and 'v-tuber' economy by 2030. | SAM $450M — Revenue potential from the cross-platform skin and avatar-customization market in web3 gaming. | SOM $12M — Initial capture from power-users and indie studios seeking granular, cost-effective identity generation on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TraitStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A generative avatar engine where every limb, texture, and trait-roll costs exactly 0.01 USDC. Users 'spend' to evolve their DNA chain in real-time, effectively micro-funding the decentralized designers whose vector assets they utilize. No minting friction; just pay-per-mutation to build a unique identity. Discipline: Game Design & Interactive Media (custom avatar creation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning high-fidelity asset rendering into a 0.01 USDC utility call, we move away from 'all-or-nothing' speculative mints to a 'pay-as-you-design' meter. Every trait selection is a micro-settlement to the asset creator. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TraitStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-procedural-provenance-5-x402 Title: SeedHash · x402 Theme: Game Design & Interactive Media (games) · algorithmic game art Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-generation engine for procedural game assets. Instead of bulk-minting static NFTs, developers pay $0.01 USDC per seed execution. Each signature triggers a unique algorithmic variation (sprites, levels, textures) delivered via a Base transaction hash, enabling a 'just-in-time' asset economy for onchain games. Why Hedera: Moves from static collection to live utility. By metering the algorithm itself, the creator is paid for the compute/creativity of the script rather than the speculation of the final token. Projects can call the API to populate infinite worlds for fractions of a cent. Market: TAM $220B — The global game art and asset production market moving toward AI and algorithmic automation. | SAM $850M — The total spent by indie developers on asset stores and procedural generation middleware. | SOM $12M — Specialized onchain game developers requiring verifiable, dynamic asset generation on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SeedHash" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-generation engine for procedural game assets. Instead of bulk-minting static NFTs, developers pay $0.01 USDC per seed execution. Each signature triggers a unique algorithmic variation (sprites, levels, textures) delivered via a Base transaction hash, enabling a 'just-in-time' asset economy for onchain games. Discipline: Game Design & Interactive Media (algorithmic game art). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves from static collection to live utility. By metering the algorithm itself, the creator is paid for the compute/creativity of the script rather than the speculation of the final token. Projects can call the API to populate infinite worlds for fractions of a cent. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SeedHash" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-interactive-comics-chain-6-x402 Title: INKFLOW · x402 Theme: Game Design & Interactive Media (games) · interactive storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Reveal the next panel. A branching, collaborative graphic novel where your micro-payment triggers the generative expansion of the narrative. No subscriptions, no ads; pay-per-frame to influence the story's direction. Every 'turn' is a signed HTS transfer transaction that instantly settles the artist and the compute agent providing the AI-assisted art. Why Hedera: Moving from 'NFT minting' (high friction, ownership focus) to 'Pay-per-Panel' (low friction, consumption focus). x402 allows for a 'Netflix-style' consumption experience with a 'Quarter-in-the-Arcade' payment model, creating a direct value link between reading a page and rewarding the creator. Market: TAM $15B — The global interactive fiction and digital collectibles market. | SAM $850M — The digital comics and webtoon market transition to micro-transaction models. | SOM $12M — Early adopters of 'Active-Reading' where AI-agents and human readers co-fund dynamic storylines on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "INKFLOW" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Reveal the next panel. A branching, collaborative graphic novel where your micro-payment triggers the generative expansion of the narrative. No subscriptions, no ads; pay-per-frame to influence the story's direction. Every 'turn' is a signed HTS transfer transaction that instantly settles the artist and the compute agent providing the AI-assisted art. Discipline: Game Design & Interactive Media (interactive storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'NFT minting' (high friction, ownership focus) to 'Pay-per-Panel' (low friction, consumption focus). x402 allows for a 'Netflix-style' consumption experience with a 'Quarter-in-the-Arcade' payment model, creating a direct value link between reading a page and rewarding the creator. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "INKFLOW" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-game-mod-provenance-7-x402 Title: SOURCECODE · x402 Theme: Game Design & Interactive Media (games) · modding community tools Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless protocol for game engines where every mod download, asset swap, or plugin load triggers a $0.01 micro-royalty to the original creator. Instead of static minting, x402 enables 'Live Provenance'—where the game client verifies the signature and pays the contributor's Magic Link email sign-in in real-time to unlock the asset data at runtime. Why Hedera: Shifts modding from a 'donation-optional' model to a high-velocity 'usage-settled' economy. By making the payment the access primitive, creators are incentivized to build interoperable assets that earn every time a player enters a new zone or loads a skin. Market: TAM $12B — The global game modding and UGC market, transitioning toward micro-transactional asset streaming. | SAM $850M — The addressable market for indie game assets, paid mod platforms (Steam Workshop/Nexus), and UGC metaverses. | SOM $12M — Initial capture targeting 'Mod-to-Earn' primitives in Base-based on-chain games and open-source engine plugins. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SOURCECODE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless protocol for game engines where every mod download, asset swap, or plugin load triggers a $0.01 micro-royalty to the original creator. Instead of static minting, x402 enables 'Live Provenance'—where the game client verifies the signature and pays the contributor's Magic Link email sign-in in real-time to unlock the asset data at runtime. Discipline: Game Design & Interactive Media (modding community tools). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts modding from a 'donation-optional' model to a high-velocity 'usage-settled' economy. By making the payment the access primitive, creators are incentivized to build interoperable assets that earn every time a player enters a new zone or loads a skin. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SOURCECODE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-level-chain-creator-8-x402 Title: ARCADE · x402 Theme: Game Design & Interactive Media (games) · game level design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized game engine where level designers are paid for every 'Play Session' initiated. Instead of buying a static NFT level, players pay 0.01 USDC to instantiate a secure play instance via their Magic Link email sign-in. Each session generates a unique Hedera transaction id, feeding into a global leaderboard where high-score rewards are funded by the level's play-fees. Designers earn passive income per play-call, turning level design into a high-frequency micro-revenue stream. Why Hedera: Shifts the model from one-time NFT sales to high-velocity micropayments. This incentivizes designers to create high-replayability levels (roguelikes, puzzles) rather than vanity assets. HTS transfer allows for frictionless 'Insert Coin' UX. Market: TAM $22B — The total addressable market for User-Generated Content (UGC) gaming and onchain creator economies. | SAM $850M — The projected market for 'pay-per-session' indie gaming and social arcade platforms on Layer 2. | SOM $12M — Transaction volume from 1.2 billion 'Pay-to-Play' level triggers across Base-native gaming ecosystems. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ARCADE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized game engine where level designers are paid for every 'Play Session' initiated. Instead of buying a static NFT level, players pay 0.01 USDC to instantiate a secure play instance via their Magic Link email sign-in. Each session generates a unique Hedera transaction id, feeding into a global leaderboard where high-score rewards are funded by the level's play-fees. Designers earn passive income per play-call, turning level design into a high-frequency micro-revenue stream. Discipline: Game Design & Interactive Media (game level design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from one-time NFT sales to high-velocity micropayments. This incentivizes designers to create high-replayability levels (roguelikes, puzzles) rather than vanity assets. HTS transfer allows for frictionless 'Insert Coin' UX. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ARCADE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-replay-provenance-9-x402 Title: GHOSTROLL · x402 Theme: Game Design & Interactive Media (games) · game replay sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless protocol where every frame of a game replay is an addressable asset. Pro-players and speedrunners publish their ghost data; users pay 0.01 USDC to 'shadow' a specific run, download the metadata for a single attempt, or unlock the input-sequence for a boss fight. Payment is the playback trigger: no pay, no replay. Creators earn instantly on every view without an ad-platform intermediary. Why Hedera: By shifting from 'NFT minting' (high friction, one-time) to 'pay-per-view ghost data' (low friction, iterative), we turn replays into a liquid utility. The x402 model treats gameplay data as a metered API, allowing for fractional access to high-skill secrets. Market: TAM $180B — The global gaming market, specifically targeting the shift toward user-generated content and skill-sharing. | SAM $450M — The competitive gaming tutorial and 'pro-guide' content market. | SOM $12M — High-stakes speedrunning communities and competitive 'ghost' data marketplaces on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GHOSTROLL" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless protocol where every frame of a game replay is an addressable asset. Pro-players and speedrunners publish their ghost data; users pay 0.01 USDC to 'shadow' a specific run, download the metadata for a single attempt, or unlock the input-sequence for a boss fight. Payment is the playback trigger: no pay, no replay. Creators earn instantly on every view without an ad-platform intermediary. Discipline: Game Design & Interactive Media (game replay sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'NFT minting' (high friction, one-time) to 'pay-per-view ghost data' (low friction, iterative), we turn replays into a liquid utility. The x402 model treats gameplay data as a metered API, allowing for fractional access to high-skill secrets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GHOSTROLL" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-xr-gesture-tokens-10-x402 Title: KINETIC · x402 Theme: Game Design & Interactive Media (games) · motion capture NFTs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A library of high-fidelity skeletal animation data accessible via payment-per-frame. Instead of buying a static NFT, game developers and AI agents stream motion data (dances, combat, gestures) directly into their engines for $0.01 per second of playback. Every millisecond of motion earns the original performer USDC in real-time, creating a live global marketplace for human motor skills. Why Hedera: By shifting from 'minting a token' to 'metering the data,' the capture becomes a liquid asset. This solves the animator's dilemma: you don't need a $500 license for one animation; you pay $0.05 to test a 'crouch' in your prototype. HTS transfer allows the engine to sign for data chunks seamlessly. Market: TAM $2.8B — The global animation and motion capture market, increasingly driven by generative AI and Metaverse interop. | SAM $450M — Motion capture libraries, indie game asset stores, and VR/AR interactive media developers needing high-quality rigged movements. | SOM $12M — AI avatar developers and indie creators building on Hedera who require instant, low-friction access to animation primitives. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A library of high-fidelity skeletal animation data accessible via payment-per-frame. Instead of buying a static NFT, game developers and AI agents stream motion data (dances, combat, gestures) directly into their engines for $0.01 per second of playback. Every millisecond of motion earns the original performer USDC in real-time, creating a live global marketplace for human motor skills. Discipline: Game Design & Interactive Media (motion capture NFTs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'minting a token' to 'metering the data,' the capture becomes a liquid asset. This solves the animator's dilemma: you don't need a $500 license for one animation; you pay $0.05 to test a 'crouch' in your prototype. HTS transfer allows the engine to sign for data chunks seamlessly. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-lore-chain-artifacts-11-x402 Title: Chronicler · x402 Theme: Game Design & Interactive Media (games) · game lore documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Lore is no longer static text; it's a metered asset. Build living world bibles where deep-lore reveals, secret histories, and character backstories are locked behind 0.01 USDC micro-unlocks. Game directors get paid per read, while players buy permanent access to specific 'fragments.' Every 'Deep Dive' signed by your Magic Link email sign-in triggers a sub-cent settlement, turning world-building into a high-frequency revenue stream instead of a one-time mint. Why Hedera: Moving from NFT minting (static) to x402 (metered consumption) aligns with how players actually consume lore: piece by piece. It monetizes the curiosity of the fanbase at a granular level rather than requiring a high-friction NFT purchase upfront. Market: TAM $2.1B — The global game narrative and transmedia storytelling market transitioning to 'Pay-per-Reveal' models. | SAM $140M — Narrative-driven indie games and TTRPG platforms integrating automated lore-payouts. | SOM $12M — Hardcore lore-hunters and wiki-contributors within the Base ecosystem. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chronicler" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Lore is no longer static text; it's a metered asset. Build living world bibles where deep-lore reveals, secret histories, and character backstories are locked behind 0.01 USDC micro-unlocks. Game directors get paid per read, while players buy permanent access to specific 'fragments.' Every 'Deep Dive' signed by your Magic Link email sign-in triggers a sub-cent settlement, turning world-building into a high-frequency revenue stream instead of a one-time mint. Discipline: Game Design & Interactive Media (game lore documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from NFT minting (static) to x402 (metered consumption) aligns with how players actually consume lore: piece by piece. It monetizes the curiosity of the fanbase at a granular level rather than requiring a high-friction NFT purchase upfront. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chronicler" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-ar-filter-provenance-12-x402 Title: LENSGATE · x402 Theme: Game Design & Interactive Media (games) · augmented reality effects Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity AR lens marketplace where users pay $0.01 USDC to 'Flash-License' a filter for a single capture session. Instead of upfront purchases, photographers and creators stream value to designers per shutter click. Metadata is signature-wrapped, ensuring only paid sessions can bypass a proprietary watermark or access premium tracking layers. Why Hedera: By shifting from NFT ownership to pay-per-use instantiation, designers capture value from viral usage rather than speculative floor prices. x402 handles the high-frequency micro-licensing required for ephemeral social media content. Market: TAM $4.2B — The global augmented reality software and digital goods market, increasingly driven by micro-transactions. | SAM $180M — The creator economy segment leveraging premium AR assets for branded content on mobile. | SOM $12M — Independent AR filter creators on Hedera seeking friction-less monetization for specialized virtual goods. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LENSGATE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity AR lens marketplace where users pay $0.01 USDC to 'Flash-License' a filter for a single capture session. Instead of upfront purchases, photographers and creators stream value to designers per shutter click. Metadata is signature-wrapped, ensuring only paid sessions can bypass a proprietary watermark or access premium tracking layers. Discipline: Game Design & Interactive Media (augmented reality effects). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from NFT ownership to pay-per-use instantiation, designers capture value from viral usage rather than speculative floor prices. x402 handles the high-frequency micro-licensing required for ephemeral social media content. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LENSGATE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-interactive-music-nfts-13-x402 Title: STEMWAVE · x402 Theme: Game Design & Interactive Media (games) · game soundtrack remixing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-stem stem-remixing engine for game soundtracks. Pay 0.01 USDC to toggle, warp, or layer high-fidelity audio tracks in real-time. Every 'Saved Session' is a micro-settled transaction that captures the unique state of the interactive score, allowing players to pay-to-play a personalized sonic experience of the game. Why Hedera: Moving from stationary NFTs to fluid, metered interaction. The value shifts from ownership of a static file to the granular control of the creative process, where every 'remix action' is a micro-transaction. Market: TAM $8.4B — Global game music and interactive media licensing market. | SAM $1.2B — Indie game players and soundtrack collectors seeking interactive music experiences. | SOM $45M — Web3 gamers and music remixers utilizing Base for low-cost asset manipulation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEMWAVE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-stem stem-remixing engine for game soundtracks. Pay 0.01 USDC to toggle, warp, or layer high-fidelity audio tracks in real-time. Every 'Saved Session' is a micro-settled transaction that captures the unique state of the interactive score, allowing players to pay-to-play a personalized sonic experience of the game. Discipline: Game Design & Interactive Media (game soundtrack remixing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from stationary NFTs to fluid, metered interaction. The value shifts from ownership of a static file to the granular control of the creative process, where every 'remix action' is a micro-transaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEMWAVE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-tokenized-game-guides-14-x402 Title: TACTIC · x402 Theme: Game Design & Interactive Media (games) · strategy content Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A dynamic, pay-per-view strategy repository where players pay 0.01 USDC to unlock specific high-level 'strats' or boss-fight sequences. Authors earn recurring revenue every time a player queries their guide to pass a level. No bulky subscriptions—just sub-cent payments for the exact tactic you need, right when you're stuck. Why Hedera: Moving away from static NFTs to x402-native micro-metering turns strategy into a liquid utility. The HTS transfer flow allows for 'Just-In-Time' tactical advice, making every high-skill sequence a revenue-generating asset for power players. Market: TAM $220B — The global gaming market, increasingly driven by UGC (User-Generated Content) and the creator economy. | SAM $850M — The addressable segment of core gamers seeking premium, competitive edges and guide-based content platforms. | SOM $12M — Strategy creators and competitive speedrunners migrating to Base for direct, per-view attribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TACTIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A dynamic, pay-per-view strategy repository where players pay 0.01 USDC to unlock specific high-level 'strats' or boss-fight sequences. Authors earn recurring revenue every time a player queries their guide to pass a level. No bulky subscriptions—just sub-cent payments for the exact tactic you need, right when you're stuck. Discipline: Game Design & Interactive Media (strategy content). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving away from static NFTs to x402-native micro-metering turns strategy into a liquid utility. The HTS transfer flow allows for 'Just-In-Time' tactical advice, making every high-skill sequence a revenue-generating asset for power players. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TACTIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-dynamic-npc-tokens-15-x402 Title: GenScript · x402 Theme: Game Design & Interactive Media (games) · procedural character design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A procedural lore engine for game devs and DMs. Pay 0.01 USDC to generate a cryptographically unique NPC profile—including SVG visual traits, personality vectors, and a lore-aligned backstory—delivered as a structured JSON object. Developers use these calls to populate open worlds on-demand, while creators earn a per-pull royalty on the underlying trait-logic they authored. No more static mints; pay for the moment of creation. Why Hedera: Shifts from a one-time NFT mint to a high-velocity utility. In procedural generation, the value is the 'roll.' By charging per-generation, the app acts as a metered API for game engines (Unity/Unreal) that need to spawn unique entities without pre-minting thousands of unused assets. Market: TAM $18B — The global procedural content generation (PCG) and character design market. | SAM $450M — The indie and web3 gaming sector integrating automated asset generation. | SOM $12M — On-chain game developers on Hedera using automated npc-spawning logic. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GenScript" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A procedural lore engine for game devs and DMs. Pay 0.01 USDC to generate a cryptographically unique NPC profile—including SVG visual traits, personality vectors, and a lore-aligned backstory—delivered as a structured JSON object. Developers use these calls to populate open worlds on-demand, while creators earn a per-pull royalty on the underlying trait-logic they authored. No more static mints; pay for the moment of creation. Discipline: Game Design & Interactive Media (procedural character design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from a one-time NFT mint to a high-velocity utility. In procedural generation, the value is the 'roll.' By charging per-generation, the app acts as a metered API for game engines (Unity/Unreal) that need to spawn unique entities without pre-minting thousands of unused assets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GenScript" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-haptic-asset-chain-16-x402 Title: PulseFlow · x402 Theme: Game Design & Interactive Media (games) · tactile feedback design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A low-latency API for XR developers to stream high-fidelity haptic waveforms directly into hardware. Bypass static libraries; pay 0.01 USDC to pull a single signature tactile 'feel' (impulse, texture, or vibration) from a global library of creator-designed sensations. Payment executes the haptic burst in-engine. Why Hedera: Moving haptics from a 'buy once' asset pack to a 'pay-per-throb' utility allows developers to access premium sensory designs without upfront costs, while creators earn micro-royalties every time their specific 'texture' is felt in a game. Market: TAM $15.5B — The global haptic technology market, including mobile, automotive, and VR/AR. | SAM $820M — The addressable market for independent XR developers and haptic hardware integration services. | SOM $14M — Captured revenue from per-use haptic triggers in emerging spatial computing and metaverse social apps. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PulseFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A low-latency API for XR developers to stream high-fidelity haptic waveforms directly into hardware. Bypass static libraries; pay 0.01 USDC to pull a single signature tactile 'feel' (impulse, texture, or vibration) from a global library of creator-designed sensations. Payment executes the haptic burst in-engine. Discipline: Game Design & Interactive Media (tactile feedback design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving haptics from a 'buy once' asset pack to a 'pay-per-throb' utility allows developers to access premium sensory designs without upfront costs, while creators earn micro-royalties every time their specific 'texture' is felt in a game. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PulseFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-ai-art-provenance-17-x402 Title: GENESIS MINT · x402 Theme: Game Design & Interactive Media (games) · AI-generated game art Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Unlock high-fidelity game assets prompt-by-prompt. Every layer, texture, and sprite generated is a unique 0.01 USDC settlement that cryptographically links the designer's intent to the final asset hash. No subscriptions; you pay for the specific creative iterations you keep. Why Hedera: By turning the provenance trail into a series of micro-transactions, the 'proof of guidance' is recorded directly on-chain as a sequence of paid state-transitions, creating a verifiable audit trail of human-AI collaboration. Market: TAM $4.2B — The global generative AI in gaming market, transitioning from bulk licensing to granular, usage-based generation. | SAM $850M — The addressable market for indie developers and modders shifting to pay-per-asset creative workflows. | SOM $12M — Early-stage game studios using Base for rapid, low-cost asset iteration and provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GENESIS MINT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Unlock high-fidelity game assets prompt-by-prompt. Every layer, texture, and sprite generated is a unique 0.01 USDC settlement that cryptographically links the designer's intent to the final asset hash. No subscriptions; you pay for the specific creative iterations you keep. Discipline: Game Design & Interactive Media (AI-generated game art). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the provenance trail into a series of micro-transactions, the 'proof of guidance' is recorded directly on-chain as a sequence of paid state-transitions, creating a verifiable audit trail of human-AI collaboration. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GENESIS MINT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-multiplayer-skin-chain-18-x402 Title: SkinStream · x402 Theme: Game Design & Interactive Media (games) · cosmetic item creation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time asset protocol where every player interaction with a cosmetic—equipping, trading, or viewing in-lobby—triggers a $0.01 micro-royalty to the original artist. No bulky upfront minting fees; pay-per-frame utility for ultra-rare skins. Non-custodial, high-velocity vanity. Why Hedera: Moving from static 'NFT minting' to 'metered usage' solves the liquidity issue for digital fashion. Instead of high barriers to entry, players 'stream' the skin's presence in-game, creating a continuous revenue loop for creators and a low-friction trial for players. Market: TAM $80B — The global video game cosmetic skins market. | SAM $420M — Web3-integrated PC/Console cosmetic ecosystems (skins, emotes, sprays). | SOM $15M — Indie multiplayer developers on Hedera using HashPack-onboarded economies. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SkinStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time asset protocol where every player interaction with a cosmetic—equipping, trading, or viewing in-lobby—triggers a $0.01 micro-royalty to the original artist. No bulky upfront minting fees; pay-per-frame utility for ultra-rare skins. Non-custodial, high-velocity vanity. Discipline: Game Design & Interactive Media (cosmetic item creation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from static 'NFT minting' to 'metered usage' solves the liquidity issue for digital fashion. Instead of high barriers to entry, players 'stream' the skin's presence in-game, creating a continuous revenue loop for creators and a low-friction trial for players. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SkinStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-puzzle-chain-creator-19-x402 Title: Enigma Meter · x402 Theme: Game Design & Interactive Media (games) · interactive puzzle design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographically-linked puzzle engine where solving or accessing the next stage requires a 0.01 USDC micro-settlement. Creators earn per-solve royalties, and players pay-per-hint or pay-per-level using X402-gated interactions. No subscriptions, just friction-less progression via signed HTS transfer intents. Why Hedera: The 'NFT ownership' model is static; X402 turns the puzzle into a metered service. By making each move or hint a 1-cent transaction, it creates a high-velocity 'micro-economy' for game designers where the financial settlement (Base tx) is the evidence of the solve. Market: TAM $12.6B — Global market for casual mobile/web puzzle games and brain-training apps. | SAM $2.4B — The estimated revenue from indie logic and puzzle games pivoting to web3-native micro-transactions. | SOM $18M — Targeted capture of on-chain puzzle enthusiasts and Base-native treasure hunt participants. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Enigma Meter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographically-linked puzzle engine where solving or accessing the next stage requires a 0.01 USDC micro-settlement. Creators earn per-solve royalties, and players pay-per-hint or pay-per-level using X402-gated interactions. No subscriptions, just friction-less progression via signed HTS transfer intents. Discipline: Game Design & Interactive Media (interactive puzzle design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: The 'NFT ownership' model is static; X402 turns the puzzle into a metered service. By making each move or hint a 1-cent transaction, it creates a high-velocity 'micro-economy' for game designers where the financial settlement (Base tx) is the evidence of the solve. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Enigma Meter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-narrative-voice-tokens-20-x402 Title: VoxStream · x402 Theme: Game Design & Interactive Media (games) · voice acting assets Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-buffer marketplace where game engines stream studio-grade voice lines directly into scenes. Instead of bulk licensing, devs pay $0.01 USDC per clip activation. Characters are 'fueled' by micropayments, ensuring voice actors receive real-time royalties every time their performance is triggered in-game. Why Hedera: Traditional licensing is too heavy for indie procedural games. By turning voice assets into x402-metered calls, we synchronize game logic with financial settlement. The HTS transfer signature turns every 'Play Audio' command into a verifiable micro-transaction. Market: TAM $4.2B — The global game voice acting and middleware market transitioning to on-chain asset management. | SAM $850M — The indie and mid-market game development sector requiring high-fidelity asset integration. | SOM $12M — AI-driven procedural RPGs and interactive media apps on Hedera using dynamic audio. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VoxStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-buffer marketplace where game engines stream studio-grade voice lines directly into scenes. Instead of bulk licensing, devs pay $0.01 USDC per clip activation. Characters are 'fueled' by micropayments, ensuring voice actors receive real-time royalties every time their performance is triggered in-game. Discipline: Game Design & Interactive Media (voice acting assets). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is too heavy for indie procedural games. By turning voice assets into x402-metered calls, we synchronize game logic with financial settlement. The HTS transfer signature turns every 'Play Audio' command into a verifiable micro-transaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VoxStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-interactive-map-nfts-21-x402 Title: CartoGrid · x402 Theme: Game Design & Interactive Media (games) · game world mapping Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn game cartography into an active revenue stream. A map server where every 'Fog of War' reveal, POI zoom, or quest-layer toggle is a $0.01 micro-transaction. Creators host the data; players pay as they explore. Integration-ready for indie RPGs via HTS transfer, enabling game engines to query high-fidelity map data one tile at a time without upfront subscriptions. Why Hedera: Shifts 'ownership' (NFT) to 'utility' (Pay-per-query). Instead of buying a static asset once, developers or players pay to stream the cartography data as needed, ensuring creators are paid for every session of exploration rather than a one-time minting event. Market: TAM $8.5B — The global game engine middleware and procedural content generation market. | SAM $420M — The market for third-party game assets and interactive lore tools for indie developers. | SOM $25M — Mid-sized RPG communities and D&D virtual tabletop (VTT) creators using custom tile-engines. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CartoGrid" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn game cartography into an active revenue stream. A map server where every 'Fog of War' reveal, POI zoom, or quest-layer toggle is a $0.01 micro-transaction. Creators host the data; players pay as they explore. Integration-ready for indie RPGs via HTS transfer, enabling game engines to query high-fidelity map data one tile at a time without upfront subscriptions. Discipline: Game Design & Interactive Media (game world mapping). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts 'ownership' (NFT) to 'utility' (Pay-per-query). Instead of buying a static asset once, developers or players pay to stream the cartography data as needed, ensuring creators are paid for every session of exploration rather than a one-time minting event. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CartoGrid" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-collectible-lore-cards-22-x402 Title: Mythos Reader · x402 Theme: Game Design & Interactive Media (games) · digital collectible design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to instantly reveal the hidden narrative metadata (lore, stats, and origins) of a digital asset. Instead of bulky NFT mints, metadata is served via x402-gated API endpoints. Creators receive instant micropayments every time a player or third-party gallery 'looks up' a card's deep history, turning provenance from a static record into a high-frequency revenue stream. Why Hedera: By moving lore off-chain and gating it with x402, we solve the 'expensive mint' problem. Users pay only to 'read' the card, allowing for trillions of lore-rich items to exist while creators earn per interaction rather than just per sale. Market: TAM $9.2B — The global digital collectible and trading card game market, shifting toward micro-transactional narrative layers. | SAM $450M — The secondary market for digital TCGs and 'gated content' collectors who pay for premium item intel. | SOM $18M — Early adopters in the Base gaming ecosystem and indie TCG developers integrating pay-per-reveal mechanics. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Mythos Reader" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to instantly reveal the hidden narrative metadata (lore, stats, and origins) of a digital asset. Instead of bulky NFT mints, metadata is served via x402-gated API endpoints. Creators receive instant micropayments every time a player or third-party gallery 'looks up' a card's deep history, turning provenance from a static record into a high-frequency revenue stream. Discipline: Game Design & Interactive Media (digital collectible design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving lore off-chain and gating it with x402, we solve the 'expensive mint' problem. Users pay only to 'read' the card, allowing for trillions of lore-rich items to exist while creators earn per interaction rather than just per sale. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Mythos Reader" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-gesture-control-assets-23-x402 Title: KINESIS · x402 Theme: Game Design & Interactive Media (games) · XR gesture interaction Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Stream high-fidelity XR spatial interaction logic directly into your Unity or Unreal engine instance. Instead of buying static asset packs, developers pay per active session call for premium hand-tracking gestures (e.g., complex spell-casting, UI telemetry). Creators earn a micro-royalty every time their specific 'pinch' or 'swipe' logic is executed in a third-party environment. HTS transfer signatures ensure low-friction, sub-second movement authorization. Why Hedera: Shifts the value from 'owning an NFT' to 'licensing an execution.' This solves the friction of upfront costs for indie XR devs while providing continuous revenue for technical animators. It turns gesture libraries into live, paid APIs. Market: TAM $1.2B — The global metaverse and spatial computing middleware market by 2027. | SAM $140M — The growing market for XR developer tools, interactive assets, and spatial computing plugins. | SOM $8M — Micro-licensing fees for independent VR/AR title developers using 'pay-as-you-interact' controller logic. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINESIS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Stream high-fidelity XR spatial interaction logic directly into your Unity or Unreal engine instance. Instead of buying static asset packs, developers pay per active session call for premium hand-tracking gestures (e.g., complex spell-casting, UI telemetry). Creators earn a micro-royalty every time their specific 'pinch' or 'swipe' logic is executed in a third-party environment. HTS transfer signatures ensure low-friction, sub-second movement authorization. Discipline: Game Design & Interactive Media (XR gesture interaction). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the value from 'owning an NFT' to 'licensing an execution.' This solves the friction of upfront costs for indie XR devs while providing continuous revenue for technical animators. It turns gesture libraries into live, paid APIs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINESIS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA games-game-font-provenance-24-x402 Title: GlyphStream · x402 Theme: Game Design & Interactive Media (games) · typography for games Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless CDN for game engines that charges 0.01 USDC per glyph-render or font-load. Instead of high-friction licensing, developers stream high-fidelity, designer-verified typography directly into game UI via HTS transfer. Every time a dialogue box pops or a UI element scales, a micro-royalty is autographed by the game's wallet and settled on Hedera, ensuring designers are paid for actual usage rather than bulk seats. Why Hedera: Traditional font licensing is broken for indie devs (too expensive) and designers (impossible to audit). x402 turns fonts into a metered utility. By moving from 'ownership' to 'micropayment per render/session,' you create a fluid market where high-quality typography is accessible for $0.01 per session, creating a continuous revenue stream for typographers. Market: TAM $2.8B — The global typography and gaming engine asset middleware market as it shifts toward asset-streaming and real-time licensing. | SAM $450M — The addressable market for indie and AA game developers seeking high-end UI assets without upfront multi-thousand dollar licensing fees. | SOM $12M — Initial capture of specialty 'lore-heavy' font designers and RPG developers on Hedera who utilize dynamic on-chain UI. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GlyphStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless CDN for game engines that charges 0.01 USDC per glyph-render or font-load. Instead of high-friction licensing, developers stream high-fidelity, designer-verified typography directly into game UI via HTS transfer. Every time a dialogue box pops or a UI element scales, a micro-royalty is autographed by the game's wallet and settled on Hedera, ensuring designers are paid for actual usage rather than bulk seats. Discipline: Game Design & Interactive Media (typography for games). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional font licensing is broken for indie devs (too expensive) and designers (impossible to audit). x402 turns fonts into a metered utility. By moving from 'ownership' to 'micropayment per render/session,' you create a fluid market where high-quality typography is accessible for $0.01 per session, creating a continuous revenue stream for typographers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GlyphStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ============================================================================== THEME · Music & Sound Design musicians, producers, composers, sound designers ============================================================================== ------------------------------------------------------------------------------ IDEA music-loop-provenance-0-x402 Title: Provenance · x402 Theme: Music & Sound Design (music) · sample tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A sub-cent attribution layer for sound design. Producers pay $0.01 USDC to instantly unlock high-fidelity stems and secure a cryptographically signed provenance receipt. Every time a sample is previewed or dragged into a DAW, the facilitator settles a micropayment directly to the original creator's Magic Link email sign-in. No subscriptions, just pay-per-sample flow for the agentic music economy. Why Hedera: By shifting from a heavy licensing model to an x402-native micropayment primitive, we remove the friction of 'clearing' samples. The payment is the tracking mechanism: the Hedera transaction id serves as the verifiable proof-of-license, allowing AI music generators and human producers to consume loops legally and instantly. Market: TAM $28B — The global music production software and licensing market, increasingly automated by AI agents. | SAM $1.2B — The total market for sample packs, loop libraries, and royalty-free music platforms transitioning to granular, per-use billing. | SOM $45M — Target capture of independent sound designers and AI music training sets requiring 0.01 USDC micro-licenses on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemCell" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn your DAW into an automated clearinghouse. Every time you drag a loop into a project, trigger a $0.01 x402 signature that verifies the sample's cryptographic origin and instantly settles a micro-royalty to the original sound designer. No more licensing guesswork—pay-per-pull provenance that protects creators and clears producers for commercial use in one click. Discipline: Music & Sound Design (sample lineage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a static database to a pay-per-use 'verification event,' the app turns provenance checking into a frictionless, high-volume transactional primitive for the music industry. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemCell" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-remix-rights-1-x402 Title: STEMS · x402 Theme: Music & Sound Design (music) · remix licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A 'Pay-per-Stems' protocol where remixers stream high-fidelity project files for 0.05 USDC per download. Every stem download triggers an immediate HTS transfer settlement to the original producer's wallet. Final remix uploads are gated by x402 signatures, ensuring the licensing fee is burned into the metadata before the track can be minted or played. licensing is no longer a contract; it's a micropayment primitive. Why Hedera: Remixing is currently deadlocked by legal friction. By turning stem access into a metered x402 event, we monetize the 'attempt' (sampling) rather than just the 'result' (distribution), creating immediate cash flow for producers and permissionless legal clearance for remixers. Market: TAM $2.4B — Total value of the global creator economy music segment. | SAM $280M — The global music licensing and royalty management market migrating toward real-time settlement. | SOM $12M — Independent electronic music producers and bedroom remixers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEMS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A 'Pay-per-Stems' protocol where remixers stream high-fidelity project files for 0.05 USDC per download. Every stem download triggers an immediate HTS transfer settlement to the original producer's wallet. Final remix uploads are gated by x402 signatures, ensuring the licensing fee is burned into the metadata before the track can be minted or played. licensing is no longer a contract; it's a micropayment primitive. Discipline: Music & Sound Design (remix licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Remixing is currently deadlocked by legal friction. By turning stem access into a metered x402 event, we monetize the 'attempt' (sampling) rather than just the 'result' (distribution), creating immediate cash flow for producers and permissionless legal clearance for remixers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEMS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-collaborative-beat-ledger-2-x402 Title: BeatCommit · x402 Theme: Music & Sound Design (music) · beat collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time rhythmic session layer where every MIDI pattern, drum hit, or sample flip is committed as a cryptographically signed stem. Producers pay 0.01 USDC to 'Drop' a loop into the shared master or 'Stem-Out' a high-quality bounce. Payments serve as the immutable proof-of-contribution, creating a financial trail of authorship that prevents ghost-producing and ensures royalty attribution at the primitive level. Why Hedera: By making every 'Commit' a paid micro-transaction, the act of creation doubles as a legal record of work. It eliminates the friction of split-sheet negotiations by replacing them with a 'pay-to-contribute' ledger. Market: TAM $2.8B — Global music production software and collaborative cloud-DAW markets. | SAM $450M — The independent 'Type Beat' economy and DAW-sync marketplace. | SOM $12M — Remote session musicians and beat-battle participants on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "BeatCommit" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time rhythmic session layer where every MIDI pattern, drum hit, or sample flip is committed as a cryptographically signed stem. Producers pay 0.01 USDC to 'Drop' a loop into the shared master or 'Stem-Out' a high-quality bounce. Payments serve as the immutable proof-of-contribution, creating a financial trail of authorship that prevents ghost-producing and ensures royalty attribution at the primitive level. Discipline: Music & Sound Design (beat collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making every 'Commit' a paid micro-transaction, the act of creation doubles as a legal record of work. It eliminates the friction of split-sheet negotiations by replacing them with a 'pay-to-contribute' ledger. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "BeatCommit" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-sound-effect-nft-vault-3-x402 Title: FoleyStream · x402 Theme: Music & Sound Design (music) · sound libraries Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency granular sound library where every 'Listen' and 'Download' is a streaming 0.01 USDC micro-settlement. Sound designers monetize individual transients, hits, and textures without subscriptions. Creators pay-per-sample to bypass licensing friction, while AI music generators use the API to pull stems in real-time. Direct HTS transfer settlement ensures the Foley artist is paid before the audio buffer even finishes playing. Why Hedera: Shifts the model from static NFT speculation to a high-velocity utility layer. By metering the actual usage (the 'listen' or the 'pull'), it captures value from both human editors and automated agents, turning a library into a live financial switchboard. Market: TAM $3.8B — The global digital music production and sample library industry. | SAM $450M — The independent sound design and stock audio marketplace. | SOM $12M — Micro-licensing for short-form social content and automated AI training data retrieval on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FoleyStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency granular sound library where every 'Listen' and 'Download' is a streaming 0.01 USDC micro-settlement. Sound designers monetize individual transients, hits, and textures without subscriptions. Creators pay-per-sample to bypass licensing friction, while AI music generators use the API to pull stems in real-time. Direct HTS transfer settlement ensures the Foley artist is paid before the audio buffer even finishes playing. Discipline: Music & Sound Design (sound libraries). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from static NFT speculation to a high-velocity utility layer. By metering the actual usage (the 'listen' or the 'pull'), it captures value from both human editors and automated agents, turning a library into a live financial switchboard. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FoleyStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-sample-swap-4-x402 Title: Resonance · x402 Theme: Music & Sound Design (music) · sample exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-audition. A high-fidelity sonic library where creators earn instantly on every preview and download. No subscriptions, just a micro-settlement for every drum hit, synth stab, or vocal chop streamed via Base. All usage rights are baked into the transaction metadata, with HTS transfer ensuring zero-friction acquisition for bedroom producers and AI-music agents alike. Why Hedera: Moving from 'swapping' to 'metered access' eliminates the double-coincidence of wants. By pricing previews at $0.01, creators monetize the curation phase, not just the final sale. Market: TAM $2.1B — The total addressable creator economy for digital audio assets and programmatic sound design. | SAM $120M — The global royalty-free sample and loop market (Splice, Arcade, Loopmasters). | SOM $8.5M — Niche creative-tech users and autonomous AI music generators requiring programmatically licensed training data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Resonance" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-audition. A high-fidelity sonic library where creators earn instantly on every preview and download. No subscriptions, just a micro-settlement for every drum hit, synth stab, or vocal chop streamed via Base. All usage rights are baked into the transaction metadata, with HTS transfer ensuring zero-friction acquisition for bedroom producers and AI-music agents alike. Discipline: Music & Sound Design (sample exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'swapping' to 'metered access' eliminates the double-coincidence of wants. By pricing previews at $0.01, creators monetize the curation phase, not just the final sale. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Resonance" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-live-set-royalty-split-5-x402 Title: PulseGate · x402 Theme: Music & Sound Design (music) · live performance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-stream triggers instant micro-settlements for high-stakes live performance. Audience members pay 0.01 USDC per minute of high-fidelity audio, or per 'Encore' request. Each payment is a signed HTS transfer message that instantly splits across the DJ, the producer, and the venue's wallet. No more waiting 6 months for PRO data; the set stays live as long as the USDC flow remains positive. Why Hedera: Shifts the model from retroactive royalty distribution to real-time 'Proof of Listening.' By making the stream metered via x402, the performance becomes a self-sustaining economic engine where each packet of sound is physically backed by a micro-payment, eliminating trust issues between collaborators. Market: TAM $1.8B — The global live music streaming and performance royalty market moving to on-chain settlement. | SAM $240M — Independent electronic music artists and underground streaming platforms using Base. | SOM $12M — High-frequency live-streamed DJ sets and modular synth performances requiring instant micro-revenue. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PulseGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-stream triggers instant micro-settlements for high-stakes live performance. Audience members pay 0.01 USDC per minute of high-fidelity audio, or per 'Encore' request. Each payment is a signed HTS transfer message that instantly splits across the DJ, the producer, and the venue's wallet. No more waiting 6 months for PRO data; the set stays live as long as the USDC flow remains positive. Discipline: Music & Sound Design (live performance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from retroactive royalty distribution to real-time 'Proof of Listening.' By making the stream metered via x402, the performance becomes a self-sustaining economic engine where each packet of sound is physically backed by a micro-payment, eliminating trust issues between collaborators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PulseGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-ai-audio-license-6-x402 Title: StemGate · x402 Theme: Music & Sound Design (music) · AI-generated music Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A sub-penny metadata wrapper for every AI-generated stem. Instead of flat-fee licensing, users pay 0.01 USDC to 'unlock' the commercial rights and provenance certificate for an individual loop or sample. High-frequency micro-licensing for producers who need high-volume, legal-clearance-on-demand. Why Hedera: Replacing monolithic licensing agreements with atomic, pay-per-stem legal clearances powered by x402 ensures that creator attribution is hardcoded into the transaction layer. Every usage has a verifiable Hedera transaction id. Market: TAM $2.8B — Total addressable market for music synch, licensing, and AI-generated assets in the creator economy. | SAM $450M — Focused on the 'Royalty-Free' sample market and independent music producers using AI tools. | SOM $12M — Target volume for initial AI-audio generators adopting x402 for 'Clearance-as-a-Service'. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A sub-penny metadata wrapper for every AI-generated stem. Instead of flat-fee licensing, users pay 0.01 USDC to 'unlock' the commercial rights and provenance certificate for an individual loop or sample. High-frequency micro-licensing for producers who need high-volume, legal-clearance-on-demand. Discipline: Music & Sound Design (AI-generated music). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Replacing monolithic licensing agreements with atomic, pay-per-stem legal clearances powered by x402 ensures that creator attribution is hardcoded into the transaction layer. Every usage has a verifiable Hedera transaction id. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-music-curriculum-7-x402 Title: Sonic Ledger · x402 Theme: Music & Sound Design (music) · music education Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-module music theory engine where every lesson, ear-training exercise, and sound design challenge requires a 0.01 USDC unlock. Students don't pay for a subscription they might not use; they pay for the exact knowledge they consume. Every successful completion triggers an immediate onchain credential, turning your learning history into a verifiable, micro-funded repertoire. Why Hedera: By shifting from a bulk 'Curriculum' to an x402 'Pay-per-Lesson' model, we eliminate the friction of high-cost courses. The micropayment acts as a 'skin-in-the-game' proof of intent for each exercise, while facilitating instant creator royalties for the musicians who designed the modules. Market: TAM $11.8B — The global online education and certification market. | SAM $450M — The digital music education and DIY instrument learning market. | SOM $12M — Web3-native students and sound designers using micro-credentials for portfolio building. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sonic Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-module music theory engine where every lesson, ear-training exercise, and sound design challenge requires a 0.01 USDC unlock. Students don't pay for a subscription they might not use; they pay for the exact knowledge they consume. Every successful completion triggers an immediate onchain credential, turning your learning history into a verifiable, micro-funded repertoire. Discipline: Music & Sound Design (music education). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a bulk 'Curriculum' to an x402 'Pay-per-Lesson' model, we eliminate the friction of high-cost courses. The micropayment acts as a 'skin-in-the-game' proof of intent for each exercise, while facilitating instant creator royalties for the musicians who designed the modules. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sonic Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-tokenized-composer-credits-8-x402 Title: LinerNote · x402 Theme: Music & Sound Design (music) · credit attribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A global, high-fidelity attribution layer where metadata is a paid event. Instead of static lists, media players and streaming protocols ping this ledger to verify and display the 'Credit Layer' of a track. Each micro-payment of 0.01 USDC triggers a signed event on Hedera that immutably proves a composer's contribution and immediately routes a fraction of the fee to the credited wallet. Truth as a service for the music industry. Why Hedera: By turning 'viewing credits' into a sub-penny transaction, you solve the 'orphaned works' problem. Creditors are paid per verification call, and platforms gain a tamper-proof audit trail for royalty payouts via Base transaction hashes. Market: TAM $26B — The global music publishing and performance rights industry currently plagued by data fragmentation and 'black box' royalties. | SAM $800M — The emerging market for on-chain music metadata, licensing synchronization, and transparent royalty reporting systems. | SOM $12M — Independent producers and session musicians on Hedera testnet using micro-attribution for initial portfolio verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LinerNote" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A global, high-fidelity attribution layer where metadata is a paid event. Instead of static lists, media players and streaming protocols ping this ledger to verify and display the 'Credit Layer' of a track. Each micro-payment of 0.01 USDC triggers a signed event on Hedera that immutably proves a composer's contribution and immediately routes a fraction of the fee to the credited wallet. Truth as a service for the music industry. Discipline: Music & Sound Design (credit attribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'viewing credits' into a sub-penny transaction, you solve the 'orphaned works' problem. Creditors are paid per verification call, and platforms gain a tamper-proof audit trail for royalty payouts via Base transaction hashes. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LinerNote" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-sound-asset-crowdfunding-9-x402 Title: Sonic Mint · x402 Theme: Music & Sound Design (music) · music funding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A 'pay-per-listen' sandbox where unreleased stems, sound kits, and demos are gated by 0.01 USDC micropayments. Every play or download triggers an immediate x402 settlement, streaming liquid funding directly to the artist's wallet to hit milestones in real-time. No subscriptions; fans fund the production at the granular level of individual playback and asset access. Why Hedera: Shifts crowdfunding from a lump-sum speculative model into a high-velocity revenue stream. It validates demand per-asset and provides creators with immediate liquidity for every 'ear' reach, powered by low-friction the embedded wallet signing. Market: TAM $12B — The global music production and royalty market. | SAM $500M — Projected volume for independent music creator tools and sample marketplaces. | SOM $15M — Early-adopter music producers and sound designers on Hedera using micropayments for asset distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sonic Mint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A 'pay-per-listen' sandbox where unreleased stems, sound kits, and demos are gated by 0.01 USDC micropayments. Every play or download triggers an immediate x402 settlement, streaming liquid funding directly to the artist's wallet to hit milestones in real-time. No subscriptions; fans fund the production at the granular level of individual playback and asset access. Discipline: Music & Sound Design (music funding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts crowdfunding from a lump-sum speculative model into a high-velocity revenue stream. It validates demand per-asset and provides creators with immediate liquidity for every 'ear' reach, powered by low-friction the embedded wallet signing. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sonic Mint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-synth-presets-10-x402 Title: PATCHLOCK · x402 Theme: Music & Sound Design (music) · synth programming Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Stop buying bulk packs to find one sound. DAW-integrated x402 plugins allow synth designers to monetize individual presets via HTS transfer. Producers pay 0.01 USDC to instantly 'patch-in' a premium sound to their session. No subscription, no bloated libraries—just a per-click logic that settles on Hedera, turning every 'Load Preset' action into a micro-royalty for the designer. Why Hedera: Micropayments solve the fragmentation of the preset market. Moving from a $50 bundle model to a $0.01 'pay-per-load' model lowers the barrier for producers while creating a high-volume, automated revenue stream for sound designers at the moment of inspiration. Market: TAM $4.2B — Total global sound library, sample pack, and software instrument market. | SAM $850M — The addressable market of independent electronic music producers and bedroom composers using VST/AU plugins. | SOM $42M — Early adopters in the web3 music space and power-users of digital synthesis platforms like Serum, Vital, and Phase Plant. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PATCHLOCK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Stop buying bulk packs to find one sound. DAW-integrated x402 plugins allow synth designers to monetize individual presets via HTS transfer. Producers pay 0.01 USDC to instantly 'patch-in' a premium sound to their session. No subscription, no bloated libraries—just a per-click logic that settles on Hedera, turning every 'Load Preset' action into a micro-royalty for the designer. Discipline: Music & Sound Design (synth programming). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Micropayments solve the fragmentation of the preset market. Moving from a $50 bundle model to a $0.01 'pay-per-load' model lowers the barrier for producers while creating a high-volume, automated revenue stream for sound designers at the moment of inspiration. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PATCHLOCK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-decentralized-jam-sessions-11-x402 Title: StemSync · x402 Theme: Music & Sound Design (music) · remote collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-layer DAW where every track addition or 'accept' action triggers a 0.01 USDC settlement, instantly securing co-authorship on-chain with a Hedera transaction id. No more royalty disputes; the protocol acts as a real-time ledger for creative provenance. Musicians pay to join the stem, and creators earn as their sounds are sampled or layered. Why Hedera: Shifts collaboration from a 'trust-based' manual log to a 'pay-to-play' atomic contribution model. Using x402 ensures that every creative input is high-intent and cryptographically linked to the session's evolving state. Market: TAM $8.2B — Global music collaboration and digital audio workstation (DAW) market. | SAM $420M — Decentralized music production and stem-license marketplaces. | SOM $18M — Independent bedroom producers and session musicians on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemSync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-layer DAW where every track addition or 'accept' action triggers a 0.01 USDC settlement, instantly securing co-authorship on-chain with a Hedera transaction id. No more royalty disputes; the protocol acts as a real-time ledger for creative provenance. Musicians pay to join the stem, and creators earn as their sounds are sampled or layered. Discipline: Music & Sound Design (remote collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts collaboration from a 'trust-based' manual log to a 'pay-to-play' atomic contribution model. Using x402 ensures that every creative input is high-intent and cryptographically linked to the session's evolving state. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemSync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-interactive-sound-nfts-12-x402 Title: Resonance · x402 Theme: Music & Sound Design (music) · dynamic audio Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time generative audio engine where every parameter shift or 'meta-tweak' is a $0.01 micro-transaction. Users don't just listen; they perform. Sign a 1-cent permit to evolve the synth patch, trigger a drum fill, or modulate the spatial reverb based on Hedera network congestion. Creators earn pure yield as fans remix their stems in real-time. Powering interactive soundtracks for the onchain metaverse where every beat drop is a settled transaction. Why Hedera: By atomizing music production into $0.01 'logic gates,' you turn a static NFT into a playable instrument. HTS transfer allows for frictionless, sub-second sound modulation that would be impossible with traditional gas-heavy interactions. Market: TAM $2.8B — Global digital music creator economy and sound design software market. | SAM $450M — The projected market for generative AI music and interactive media licensing. | SOM $12M — Onchain audiophiles, DJs, and metaverse builders using Base for low-cost asset management. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Resonance" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time generative audio engine where every parameter shift or 'meta-tweak' is a $0.01 micro-transaction. Users don't just listen; they perform. Sign a 1-cent permit to evolve the synth patch, trigger a drum fill, or modulate the spatial reverb based on Hedera network congestion. Creators earn pure yield as fans remix their stems in real-time. Powering interactive soundtracks for the onchain metaverse where every beat drop is a settled transaction. Discipline: Music & Sound Design (dynamic audio). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By atomizing music production into $0.01 'logic gates,' you turn a static NFT into a playable instrument. HTS transfer allows for frictionless, sub-second sound modulation that would be impossible with traditional gas-heavy interactions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Resonance" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-remix-competitions-13-x402 Title: STEMWARS · x402 Theme: Music & Sound Design (music) · contest management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Submit stems or cast votes in real-time global remix battles. Each stem upload, loop download, and ranked-choice vote triggers a 0.01 USDC micro-settlement, building a sovereign prize pool that streams instantly to the top 3 producers upon contest close. No gatekeepers, just math and melody. Why Hedera: By commoditizing the 'vote' and the 'entry' as micro-payments, we eliminate bot-spam and ensure the prize pool is proportional to the contest's actual engagement. x402 allows for high-velocity participation without the friction of large entry fees. Market: TAM $12B — Global music production software and creator economy competitions. | SAM $420M — Web3 music platform revenue and decentralized autonomous artist collectives. | SOM $18M — Targeted 'Battle' niche for electronic music producers and bedroom beatmakers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEMWARS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Submit stems or cast votes in real-time global remix battles. Each stem upload, loop download, and ranked-choice vote triggers a 0.01 USDC micro-settlement, building a sovereign prize pool that streams instantly to the top 3 producers upon contest close. No gatekeepers, just math and melody. Discipline: Music & Sound Design (contest management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By commoditizing the 'vote' and the 'entry' as micro-payments, we eliminate bot-spam and ensure the prize pool is proportional to the contest's actual engagement. x402 allows for high-velocity participation without the friction of large entry fees. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEMWARS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-sample-chain-marketplace-14-x402 Title: SONIC FLOW · x402 Theme: Music & Sound Design (music) · music marketplaces Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Every audition is a micro-license. Pay to listen, pay to drag-and-drop. Samples are metered at the DAW level via HTS transfer. No subscriptions, just raw utility for producers and autonomous AI composers who need high-fidelity stems for real-time generative scoring. Settlement triggers a Hedera transaction id, instantly routing royalties to the creator's wallet. Why Hedera: By shifting from a bulk purchase model to a per-audition/per-download model, the marketplace captures value at the moment of inspiration. It removes the friction of 'credit packs' and enables machine-to-machine commerce where AI agents can buy samples mid-track. Market: TAM $2.1B — The global music sample and digital asset marketplace for human and AI creators. | SAM $480M — The segment of the royalty-free market moving toward per-use granular licensing and collaborative AI music tools. | SOM $12M — Initial volume from boutique sound designers and dev-heavy bedroom producers using automated composition tools on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SONIC FLOW" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Every audition is a micro-license. Pay to listen, pay to drag-and-drop. Samples are metered at the DAW level via HTS transfer. No subscriptions, just raw utility for producers and autonomous AI composers who need high-fidelity stems for real-time generative scoring. Settlement triggers a Hedera transaction id, instantly routing royalties to the creator's wallet. Discipline: Music & Sound Design (music marketplaces). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a bulk purchase model to a per-audition/per-download model, the marketplace captures value at the moment of inspiration. It removes the friction of 'credit packs' and enables machine-to-machine commerce where AI agents can buy samples mid-track. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SONIC FLOW" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-audio-stems-15-x402 Title: DECONSTRUCT · x402 Theme: Music & Sound Design (music) · stem distribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A low-latency delivery layer for high-fidelity audio assets. Creators upload stems to decentralized storage; developers and remixers pay a per-track micropayment to fetch the raw stems directly into their DAW or AI-remixing agent. Each 0.01 USDC call triggers a real-time royalty split to the original producer and session musicians. No subscriptions, just pay-per-stem access for granular sampling. Why Hedera: Stem distribution is currently bottlenecked by high platform fees and 'all-or-nothing' subscription models. x402 allows for 'micro-licensing'—where an AI music generator or a bedroom producer can programmatically pull only the 'Drum' or 'Vocal' stem for a cent, enabling a fluid, usage-based creative economy. Market: TAM $4.2B — The global music production and royalty management industry. | SAM $850M — The sample and loop licensing market (Splice, Loopcloud) shifting to granular, unbundled access. | SOM $12M — Independent electronic music producers and AI-audio startups on Hedera requiring programmatic asset retrieval. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DECONSTRUCT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A low-latency delivery layer for high-fidelity audio assets. Creators upload stems to decentralized storage; developers and remixers pay a per-track micropayment to fetch the raw stems directly into their DAW or AI-remixing agent. Each 0.01 USDC call triggers a real-time royalty split to the original producer and session musicians. No subscriptions, just pay-per-stem access for granular sampling. Discipline: Music & Sound Design (stem distribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Stem distribution is currently bottlenecked by high platform fees and 'all-or-nothing' subscription models. x402 allows for 'micro-licensing'—where an AI music generator or a bedroom producer can programmatically pull only the 'Drum' or 'Vocal' stem for a cent, enabling a fluid, usage-based creative economy. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DECONSTRUCT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-blockchain-soundscapes-16-x402 Title: ATMOS · x402 Theme: Music & Sound Design (music) · environmental audio Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered API for high-fidelity environmental audio. Pay 0.01 USDC per second of spatial audio stream or per asset download. Every soundscape is an on-chain primitive that permits instantaneous micro-licensing for game engines, VR environments, and meditation apps without subscription overhead. Pay exactly for the atmosphere you use. Why Hedera: Traditional licensing is too heavy for procedural generation and small-scale dev. By moving to pay-per-buffer-fill or pay-per-asset using x402, we turn environmental audio into a liquid commodity that AI agents and game engines can consume programmatically. Market: TAM $2.4B — The global stock media and sound effect licensing industry transitioning to real-time, metered delivery. | SAM $120M — The indie game developer and spatial computing market requiring high-quality, royalty-free-but-paid assets. | SOM $8.5M — Niche procedural audio plugins and VR meditation platforms integrating x402 micropayments for real-time asset hydration. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ATMOS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered API for high-fidelity environmental audio. Pay 0.01 USDC per second of spatial audio stream or per asset download. Every soundscape is an on-chain primitive that permits instantaneous micro-licensing for game engines, VR environments, and meditation apps without subscription overhead. Pay exactly for the atmosphere you use. Discipline: Music & Sound Design (environmental audio). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is too heavy for procedural generation and small-scale dev. By moving to pay-per-buffer-fill or pay-per-asset using x402, we turn environmental audio into a liquid commodity that AI agents and game engines can consume programmatically. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ATMOS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-decentralized-music-feedback-17-x402 Title: SonicAudit · x402 Theme: Music & Sound Design (music) · peer review Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: High-stakes peer review for producers. Upload stems and stake 0.01 USDC per second of feedback. Professional listeners earn instantly for every timestamped critique dropped. Every comment is a settled transaction, ensuring only high-signal mentorship survives the noise. Why Hedera: By turning feedback into a metered micropayment, we solve the 'passive listening' problem. Critics are compensated for their time in real-time, and artists pay only for active, verified engagement. Settlement on Hedera ensures a permanent, verifiable proof-of-critique record. Market: TAM $2.4B — Global music production and education market. | SAM $180M — Independent producers and session musicians using paid feedback loops. | SOM $12M — Web3-native music collectives and DAOs on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SonicAudit" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT High-stakes peer review for producers. Upload stems and stake 0.01 USDC per second of feedback. Professional listeners earn instantly for every timestamped critique dropped. Every comment is a settled transaction, ensuring only high-signal mentorship survives the noise. Discipline: Music & Sound Design (peer review). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning feedback into a metered micropayment, we solve the 'passive listening' problem. Critics are compensated for their time in real-time, and artists pay only for active, verified engagement. Settlement on Hedera ensures a permanent, verifiable proof-of-critique record. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SonicAudit" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-dj-set-logs-18-x402 Title: CUEPOINT · x402 Theme: Music & Sound Design (music) · performance archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for granular performance telemetry. DJs push real-time MIDI metadata and track transitions to a live stream; listeners or archival bots pay 0.01 USDC per 'Sync' to unlock the precise tracklist, timestamped cue points, and energy-density data for the current segment. High-fidelity metadata becomes a purchasable asset for curators and algorithmic radio stations. Why Hedera: By gating every 'Sync' event (track transition or metadata refresh) behind a micropayment, the DJ converts their live selection skill into a metered stream of revenue. It solves the copyright attribution problem by making the 'log' a paid verifiable proof of play. Market: TAM $1.2B (The global music derivatives, performance rights, and metadata industry). | SAM $85M (Global live electronic music market and digital metadata licensing). | SOM $4.2M (Active Web3 DJs and festival data-archiving partners). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CUEPOINT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for granular performance telemetry. DJs push real-time MIDI metadata and track transitions to a live stream; listeners or archival bots pay 0.01 USDC per 'Sync' to unlock the precise tracklist, timestamped cue points, and energy-density data for the current segment. High-fidelity metadata becomes a purchasable asset for curators and algorithmic radio stations. Discipline: Music & Sound Design (performance archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By gating every 'Sync' event (track transition or metadata refresh) behind a micropayment, the DJ converts their live selection skill into a metered stream of revenue. It solves the copyright attribution problem by making the 'log' a paid verifiable proof of play. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CUEPOINT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-smart-contract-audio-rng-19-x402 Title: Resonance · x402 Theme: Music & Sound Design (music) · generative music Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An x402-metered infinite radio. Every 'Next Track' call triggers a seed-based generative synthesis engine. Pay 0.01 USDC to roll a unique audio seed and stream a never-before-heard, verifiable MIDI-to-audio sequence. High-frequency micro-licensing for streamers and creators who need royalty-free backgrounds instantly. No minting friction, just pay-per-buffer. Why Hedera: Turning 'NFT generation' into 'pay-per-stream' aligns with x402’s strength: high-velocity micropayments. It shifts from a high-friction asset purchase to a low-friction utility (RNG-as-a-service). Market: TAM $2.8B — Global generative AI media and procedural sound design industry. | SAM $450M — The royalty-free music and background audio market for digital creators. | SOM $12M — Onchain streamers and AI-agent devs requiring verifiable, non-copyright audio stems. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Resonance" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An x402-metered infinite radio. Every 'Next Track' call triggers a seed-based generative synthesis engine. Pay 0.01 USDC to roll a unique audio seed and stream a never-before-heard, verifiable MIDI-to-audio sequence. High-frequency micro-licensing for streamers and creators who need royalty-free backgrounds instantly. No minting friction, just pay-per-buffer. Discipline: Music & Sound Design (generative music). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Turning 'NFT generation' into 'pay-per-stream' aligns with x402’s strength: high-velocity micropayments. It shifts from a high-friction asset purchase to a low-friction utility (RNG-as-a-service). 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Resonance" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-token-gated-music-releases-20-x402 Title: StemStream · x402 Theme: Music & Sound Design (music) · exclusive content Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Stream unreleased stems, demos, and master tracks with zero friction. No subscriptions or NFTs required—just pay 0.01 USDC per play. Each listen triggers an instant settlement to the artist's wallet via HTS transfer, creating a high-velocity 'pay-per-ear' micro-economy for independent sound designers and musicians. Why Hedera: Moving away from binary 'buy/own' token gates to 'metered access' lowers the barrier to entry for fans while ensuring artists get paid for every single session. x402 handles the micro-settlement without the friction of traditional minting or subscription paywalls. Market: TAM $8.2B — The global music streaming and digital downloads market, shifting toward decentralized, granular payment models. | SAM $450M — The projected market for independent music creators and direct-to-fan monetization platforms by 2026. | SOM $12M — Initial capture of underground electronic and lo-fi producers using Base for rapid, low-fee distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Stream unreleased stems, demos, and master tracks with zero friction. No subscriptions or NFTs required—just pay 0.01 USDC per play. Each listen triggers an instant settlement to the artist's wallet via HTS transfer, creating a high-velocity 'pay-per-ear' micro-economy for independent sound designers and musicians. Discipline: Music & Sound Design (exclusive content). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving away from binary 'buy/own' token gates to 'metered access' lowers the barrier to entry for fans while ensuring artists get paid for every single session. x402 handles the micro-settlement without the friction of traditional minting or subscription paywalls. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-vocal-processing-21-x402 Title: VocalStem · x402 Theme: Music & Sound Design (music) · vocal effects Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Transform any raw vocal recording into a studio-grade stem via high-fidelity effect chains. Pay 0.01 USDC per process to unlock specialized racks (De-Ess, Autotune, Saturation) curated by top engineers. Sign to process; receive your processed audio and a Hedera transaction id validating the logic used. Why Hedera: By moving from 'presets as NFTs' to 'processing as a service,' you monetize the actual utility. x402 allows vocalists to 'rent' an expensive vocal chain for a fraction of a cent per bounce, rather than buying software licenses. Market: TAM $2.8B — Global digital audio workstation and signal processing software market. | SAM $450M — The growing market for boutique audio plugins and cloud-based mixing services. | SOM $12M — Web3-native vocalists and bedroom producers using mobile DAWs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VocalStem" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Transform any raw vocal recording into a studio-grade stem via high-fidelity effect chains. Pay 0.01 USDC per process to unlock specialized racks (De-Ess, Autotune, Saturation) curated by top engineers. Sign to process; receive your processed audio and a Hedera transaction id validating the logic used. Discipline: Music & Sound Design (vocal effects). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'presets as NFTs' to 'processing as a service,' you monetize the actual utility. x402 allows vocalists to 'rent' an expensive vocal chain for a fraction of a cent per bounce, rather than buying software licenses. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VocalStem" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-music-metadata-registry-22-x402 Title: SoundCheck · x402 Theme: Music & Sound Design (music) · metadata management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity metadata oracle for the on-chain music economy. Pay 0.01 USDC per query to fetch globally verified ISRC, BPM, and publishing data. Ingesting high-quality metadata is no longer a manual chore—it is a metered API call that ensures royalty accuracy for DSPs and DJ software. Why Hedera: Moving from a static registry to a metered oracle turns metadata into a 'proof-of-verification' service. By charging per lookup, the protocol incentivizes high-uptime nodes and data accuracy while preventing industrial-scale scraping without compensation. Market: TAM $12B — The total addressable global market for music rights management and metadata administration. | SAM $4.2B — Professional music data services, sync licensing markets, and automated royalty distribution systems. | SOM $850k — Initial integration with web3 streaming platforms, DAOs managing catalog buyouts, and on-chain DJ tools on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SoundCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity metadata oracle for the on-chain music economy. Pay 0.01 USDC per query to fetch globally verified ISRC, BPM, and publishing data. Ingesting high-quality metadata is no longer a manual chore—it is a metered API call that ensures royalty accuracy for DSPs and DJ software. Discipline: Music & Sound Design (metadata management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a static registry to a metered oracle turns metadata into a 'proof-of-verification' service. By charging per lookup, the protocol incentivizes high-uptime nodes and data accuracy while preventing industrial-scale scraping without compensation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SoundCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-smart-contract-sync-licenses-23-x402 Title: StemStream · x402 Theme: Music & Sound Design (music) · sync licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Sync licensing moves from manual lawyer-gated silos to open-metered assets. Every time an editor, AI video generator, or content creator previews a high-fidelity stem in their timeline, 0.01 USDC is streamed to the rights holders. Final broadcast 'unlock' triggers a batch settlement. Pay-per-preview turns passive catalogs into active revenue streams where use equals immediate settlement. Why Hedera: Traditional sync is friction-heavy. By making sound libraries x402-native, creators pay a frictionless micropayment to audition stems in high quality, removing the need for watermarking and manual invoicing while ensuring fair compensation for AI-driven usage. Market: TAM $2.3B — Global music synchronization and digital licensing market. | SAM $850M — The share of the sync market driven by creators, small agencies, and AI-video platforms requiring instant clearing. | SOM $12M — The immediate capture of independent music libraries on Hedera seeking x402-integrated DAW plugins. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Sync licensing moves from manual lawyer-gated silos to open-metered assets. Every time an editor, AI video generator, or content creator previews a high-fidelity stem in their timeline, 0.01 USDC is streamed to the rights holders. Final broadcast 'unlock' triggers a batch settlement. Pay-per-preview turns passive catalogs into active revenue streams where use equals immediate settlement. Discipline: Music & Sound Design (sync licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional sync is friction-heavy. By making sound libraries x402-native, creators pay a frictionless micropayment to audition stems in high quality, removing the need for watermarking and manual invoicing while ensuring fair compensation for AI-driven usage. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-audio-watermarking-24-x402 Title: SonicStamp · x402 Theme: Music & Sound Design (music) · copyright protection Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Protect audio assets instantly. Every time an AI scraper, producer, or platform requests the 'Proof of Origin' data for a track, you receive a micropayment. Payment triggers the verified cryptographic signature that authenticates your ownership and licensing rights on-chain. Turn copyright verification from a legal hurdle into a high-frequency revenue stream. Why Hedera: Traditional watermarking is passive; this makes it active. By metering the verification process, creators are paid every time their work is audited or ingested by external systems. x402 handles the millions of tiny transactions that occur when AI models train on or platforms scan large libraries. Market: TAM $4.5B — The global digital rights management and AI-training data verification market. | SAM $220M — The addressable market for independent music producers and sound designers seeking automated licensing. | SOM $8M — Initial capture of royalty-tracking for royalty-free sample packs and stems on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SonicStamp" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Protect audio assets instantly. Every time an AI scraper, producer, or platform requests the 'Proof of Origin' data for a track, you receive a micropayment. Payment triggers the verified cryptographic signature that authenticates your ownership and licensing rights on-chain. Turn copyright verification from a legal hurdle into a high-frequency revenue stream. Discipline: Music & Sound Design (copyright protection). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional watermarking is passive; this makes it active. By metering the verification process, creators are paid every time their work is audited or ingested by external systems. x402 handles the millions of tiny transactions that occur when AI models train on or platforms scan large libraries. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SonicStamp" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-loop-provenance-0-x402 Title: StemCell · x402 Theme: Music & Sound Design (music) · sample lineage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn your DAW into an automated clearinghouse. Every time you drag a loop into a project, trigger a $0.01 x402 signature that verifies the sample's cryptographic origin and instantly settles a micro-royalty to the original sound designer. No more licensing guesswork—pay-per-pull provenance that protects creators and clears producers for commercial use in one click. Why Hedera: By shifting from a static database to a pay-per-use 'verification event,' the app turns provenance checking into a frictionless, high-volume transactional primitive for the music industry. Market: TAM $4.2B — The total addressable market of digital music assets, AI training data for audio, and creator-economy copyright services. | SAM $850M — The global production music and sample pack market moving toward granular, per-use licensing. | SOM $12M — Independent producers and sound designers on Hedera utilizing micro-settlements for loop consumption. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemCell" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn your DAW into an automated clearinghouse. Every time you drag a loop into a project, trigger a $0.01 x402 signature that verifies the sample's cryptographic origin and instantly settles a micro-royalty to the original sound designer. No more licensing guesswork—pay-per-pull provenance that protects creators and clears producers for commercial use in one click. Discipline: Music & Sound Design (sample lineage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a static database to a pay-per-use 'verification event,' the app turns provenance checking into a frictionless, high-volume transactional primitive for the music industry. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemCell" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-patch-vault-1-x402 Title: Oscillator · x402 Theme: Music & Sound Design (music) · synth preset archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographically versioned preset library where sound designers earn 0.01 USDC every time a user auditions or imports a patch. By integrating x402, every 'Load' command in the DAW triggers a microscopic settlement, turning a passive archive into an active, metered revenue stream for synth nerds. No subscriptions, just payment at the point of inspiration. Why Hedera: Moving from 'vault storage' to 'unit-based consumption' solves the discovery problem for creators. Instead of selling packs, they sell usage. The facilitator settles the transaction instantly, ensuring the designer is paid before the sound is even modulated. Market: TAM $4.2B — The global music production software and content creator economy shifted to per-use licensing. | SAM $850M — The digital instruments and VST plugin market adopting micro-liquidity models. | SOM $12M — Independent sound designers and power users on Hedera testnet experimenting with HTS transfer metered libraries. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Oscillator" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographically versioned preset library where sound designers earn 0.01 USDC every time a user auditions or imports a patch. By integrating x402, every 'Load' command in the DAW triggers a microscopic settlement, turning a passive archive into an active, metered revenue stream for synth nerds. No subscriptions, just payment at the point of inspiration. Discipline: Music & Sound Design (synth preset archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'vault storage' to 'unit-based consumption' solves the discovery problem for creators. Instead of selling packs, they sell usage. The facilitator settles the transaction instantly, ensuring the designer is paid before the sound is even modulated. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Oscillator" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-score-archive-2-x402 Title: Immutable Note · x402 Theme: Music & Sound Design (music) · composition notation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn musical notation into a verifiable sequence of micro-copyrights. Pay 0.01 USDC to mint a permanent, timestamped cryptographic proof of a musical phrase or full score. Prevents 'accidental' plagiarism and establishes verifiable provenance for composers before they share drafts with collaborators or labels. Every edit is a new state, every state is an on-chain receipt. Why Hedera: By moving composition notation from 'cloud storage' to 'pay-per-commit,' the act of saving becomes a legal and economic anchor. x402 allows for high-frequency version control where each 'Save' is a micro-settlement on Hedera, providing the immutable paper trail necessary for modern IP disputes. Market: TAM $4.5B — Global music copyright and intellectual property protection industry. | SAM $1.2B — Music publishing market software and independent composers utilizing digital notation tools (Sibelius, MuseScore users). | SOM $85M — Indie composers, film scorers, and ghostwriters on Hedera requiring instant, low-cost verifiable timestamps for work-in-progress. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Immutable Note" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn musical notation into a verifiable sequence of micro-copyrights. Pay 0.01 USDC to mint a permanent, timestamped cryptographic proof of a musical phrase or full score. Prevents 'accidental' plagiarism and establishes verifiable provenance for composers before they share drafts with collaborators or labels. Every edit is a new state, every state is an on-chain receipt. Discipline: Music & Sound Design (composition notation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving composition notation from 'cloud storage' to 'pay-per-commit,' the act of saving becomes a legal and economic anchor. x402 allows for high-frequency version control where each 'Save' is a micro-settlement on Hedera, providing the immutable paper trail necessary for modern IP disputes. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Immutable Note" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-samplechain-3-x402 Title: Splicer · x402 Theme: Music & Sound Design (music) · sample licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-clear engine for crate-digging. Producers pay $0.01 to instantly meter a high-fidelity sample for a 24-hour creative window. No upfront buyout; just signature-based micro-licenses as you compose. Clearance is settled per-listen or per-export, signed by the Magic Link email sign-in, returning a permanent Base settlement hash as your legal proof of use. Why Hedera: By turning licensing into a metered stream, we remove the friction of legal negotiation for bedroom producers. x402 allows the license to be as granular as the music—charging per 'unlock' of a stem—ensuring rights holders get paid for every creative attempt while producers only pay for what they actually use in the session. Market: TAM $4.5B — Global music sampling and synchronization market moving toward automated, real-time micro-transactions. | SAM $180M — The independent 'type-beat' and loop-kit market segment transacting via crypto-native DAWs. | SOM $12M — High-velocity bedroom producers on Hedera requiring instant, micro-legal clearance for social media distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Splicer" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-clear engine for crate-digging. Producers pay $0.01 to instantly meter a high-fidelity sample for a 24-hour creative window. No upfront buyout; just signature-based micro-licenses as you compose. Clearance is settled per-listen or per-export, signed by the Magic Link email sign-in, returning a permanent Base settlement hash as your legal proof of use. Discipline: Music & Sound Design (sample licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning licensing into a metered stream, we remove the friction of legal negotiation for bedroom producers. x402 allows the license to be as granular as the music—charging per 'unlock' of a stem—ensuring rights holders get paid for every creative attempt while producers only pay for what they actually use in the session. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Splicer" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-mix-history-4-x402 Title: StemStream · x402 Theme: Music & Sound Design (music) · mix versioning Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A version-controlled audio vault where every bounce is an on-chain event. Engineers pay 0.01 USDC to commit a mix iteration, generating an immutable proof-of-lineage hash. Clients pay 0.01 USDC to unlock and stream specific versions, ensuring session transparency and preventing unpaid 'final' file handovers. Why Hedera: By turning mix versioning into a metered transaction, payment becomes the proof of delivery. It solves the 'endless revisions' problem by charging a micro-fee for every bounce and audition, framing the creative process as a verifiable ledger of work. Market: TAM $4.2B — Global music production and audio engineering software market (SaaS + middleware). | SAM $180M — Independent mix engineers and boutique post-production houses transitioning to pay-as-you-go workflows. | SOM $12M — Remote session musicians and engineers on Hedera using HashPack-integrated DAW tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A version-controlled audio vault where every bounce is an on-chain event. Engineers pay 0.01 USDC to commit a mix iteration, generating an immutable proof-of-lineage hash. Clients pay 0.01 USDC to unlock and stream specific versions, ensuring session transparency and preventing unpaid 'final' file handovers. Discipline: Music & Sound Design (mix versioning). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning mix versioning into a metered transaction, payment becomes the proof of delivery. It solves the 'endless revisions' problem by charging a micro-fee for every bounce and audition, framing the creative process as a verifiable ledger of work. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-soundscape-atlas-5-x402 Title: Resonance · x402 Theme: Music & Sound Design (music) · field recording Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular, pay-per-buffer library for professional sound designers and AI training sets. Bypass subscription bloat by paying 0.01 USDC per high-fidelity lossless download or API stream. Every field recording is a smart-contract asset where micropayments flow directly to the recordist's Magic Link email sign-in, enabling a global, decentralized 'Street View' for audio. Why Hedera: Professional sound design often requires specific, one-off textures (e.g., 'Tokyo subway at 3 AM'). Subscription models overcharge for this, while free libraries lack quality. x402 enables a 'pay-per-ear' model where high-quality metadata is free to browse, but the raw audio asset is gated by an instant 0.01 USDC settlement. Market: TAM $3.8B — The global stock media and sound effects market, including AI training data licensing for generative audio models. | SAM $450M — The segment of the sound library market shifting toward 'micro-licensing' and rapid-prototyping for indie game devs and TikTok creators. | SOM $12M — Initial volume from ambient AI music generators and VR developers requiring authentic, geolocated environmental assets via API. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Resonance" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular, pay-per-buffer library for professional sound designers and AI training sets. Bypass subscription bloat by paying 0.01 USDC per high-fidelity lossless download or API stream. Every field recording is a smart-contract asset where micropayments flow directly to the recordist's Magic Link email sign-in, enabling a global, decentralized 'Street View' for audio. Discipline: Music & Sound Design (field recording). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Professional sound design often requires specific, one-off textures (e.g., 'Tokyo subway at 3 AM'). Subscription models overcharge for this, while free libraries lack quality. x402 enables a 'pay-per-ear' model where high-quality metadata is free to browse, but the raw audio asset is gated by an instant 0.01 USDC settlement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Resonance" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-preset-gallery-6-x402 Title: Patchbay · x402 Theme: Music & Sound Design (music) · plugin preset sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered sound-design marketplace where DAWs pull verified .fxp and .adg presets directly from a Base-backed vault. Instead of bloated subscriptions, producers pay a 0.01 USDC micro-transaction to preview or commit to a preset. Top-rated sound designers earn continuous royalties as their signature patches are 'called' by AI generators or human composers in real-time. Why Hedera: By turning presets into pay-per-use assets, we eliminate the friction of $50 preset packs. $0.01 is a negligible friction for a 'perfect' snare sound, but creates a high-velocity revenue stream for creators when integrated directly into the producer's workflow via x402. Market: TAM $2.1B — The global music production software market, increasingly shifting toward subscription-less, modular asset acquisition. | SAM $180M — The addressable segment of independent bedroom producers and specialized sound designers using VST/AU plugins. | SOM $12M — Revenue from high-frequency preset 'calls' within creative AI orchestration layers and decentralized DAW extensions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Patchbay" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered sound-design marketplace where DAWs pull verified .fxp and .adg presets directly from a Base-backed vault. Instead of bloated subscriptions, producers pay a 0.01 USDC micro-transaction to preview or commit to a preset. Top-rated sound designers earn continuous royalties as their signature patches are 'called' by AI generators or human composers in real-time. Discipline: Music & Sound Design (plugin preset sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning presets into pay-per-use assets, we eliminate the friction of $50 preset packs. $0.01 is a negligible friction for a 'perfect' snare sound, but creates a high-velocity revenue stream for creators when integrated directly into the producer's workflow via x402. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Patchbay" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-vinyl-metadata-7-x402 Title: GrooveCheck · x402 Theme: Music & Sound Design (music) · record collection Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Archive and verify high-fidelity provenance. Collectors pay per-lookup to fetch global market data, press variants, and master-tape origins. Sellers pay per-mint to generate a permanent metadata hash for physical records, ending 'condition' disputes via immutable proof-of-state. A pay-per-scan interface for the analog enthusiast. Why Hedera: Transitioning static metadata into a per-query utility creates a low-friction valuation tool. x402 handles the micro-cost of API calls to Discogs/MusicBrainz APIs, turning a utility into a micro-revenue stream for the provider. Market: TAM $5.5B — Global vinyl record market and archival collectibles economy moving toward digital twins and provenance tracking. | SAM $420M — Pro-sumer segment and power-sellers on Discogs/eBay requiring instant data verification for high-value assets. | SOM $15M — Early adopters in the web3-native vinyl community and boutique record store clerks using mobile-first tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GrooveCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Archive and verify high-fidelity provenance. Collectors pay per-lookup to fetch global market data, press variants, and master-tape origins. Sellers pay per-mint to generate a permanent metadata hash for physical records, ending 'condition' disputes via immutable proof-of-state. A pay-per-scan interface for the analog enthusiast. Discipline: Music & Sound Design (record collection). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning static metadata into a per-query utility creates a low-friction valuation tool. x402 handles the micro-cost of API calls to Discogs/MusicBrainz APIs, turning a utility into a micro-revenue stream for the provider. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GrooveCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-moodsampler-8-x402 Title: MoodSampler · x402 Theme: Music & Sound Design (music) · emotional tagging Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: MoodSampler turns audio tagging into a sovereign high-fidelity label market. Pay 0.01 USDC per HTS transfer signature to instantly append cryptographically verified emotional metadata to any sample. Each 'tag' is a micro-settlement that ensures the curator is compensated for their sonic intuition, creating a decentralized database of human-felt sound where every search query and metadata unlock feeds the Base creator ecosystem. Why Hedera: By pricing metadata attachment and discovery at the atomic level, we solve the 'messy library' problem for producers while turning curation into a micro-revenue stream. Moving from free tags to x402-native micropayments ensures metadata quality and provenance. Market: TAM $4.2B — The total creator economy segment for sample-based music production and licensing. | SAM $850M — The global production music and stock audio market. | SOM $12M — On-chain sound designers and DAW-integrated agentic music workflows. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MoodSampler" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT MoodSampler turns audio tagging into a sovereign high-fidelity label market. Pay 0.01 USDC per HTS transfer signature to instantly append cryptographically verified emotional metadata to any sample. Each 'tag' is a micro-settlement that ensures the curator is compensated for their sonic intuition, creating a decentralized database of human-felt sound where every search query and metadata unlock feeds the Base creator ecosystem. Discipline: Music & Sound Design (emotional tagging). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By pricing metadata attachment and discovery at the atomic level, we solve the 'messy library' problem for producers while turning curation into a micro-revenue stream. Moving from free tags to x402-native micropayments ensures metadata quality and provenance. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MoodSampler" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-beatledger-9-x402 Title: BeatLock · x402 Theme: Music & Sound Design (music) · beat ownership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A trustless protocol for beat-licensing where 0.01 USDC buys a duration-limited 'Commercial Use Voice-Tag' removal. Producers upload instrumentals protected by a dynamic audio watermark; listeners and artists sign HTS transfer transactions to unlock high-fidelity, tag-free streams per-minute or per-download. Ownership is verified by the transaction hash, turning every play into a micro-royalty settlement. Why Hedera: Current beat marketplaces suffer from high platform fees and 'all-or-nothing' licensing. x402 enables granular metering (pay-per-preview or pay-per-bar), allowing artists to prototype with beats for pennies before committing to full buyouts. Market: TAM $8.2B — The global music production and royalty management market migrating to on-chain settlement. | SAM $520M — Professional home-studio recording artists and independent rappers globally. | SOM $14M — Early-adopter beat-leasers on Hedera and decentralized music platforms like Audius. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "BeatLock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A trustless protocol for beat-licensing where 0.01 USDC buys a duration-limited 'Commercial Use Voice-Tag' removal. Producers upload instrumentals protected by a dynamic audio watermark; listeners and artists sign HTS transfer transactions to unlock high-fidelity, tag-free streams per-minute or per-download. Ownership is verified by the transaction hash, turning every play into a micro-royalty settlement. Discipline: Music & Sound Design (beat ownership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current beat marketplaces suffer from high platform fees and 'all-or-nothing' licensing. x402 enables granular metering (pay-per-preview or pay-per-bar), allowing artists to prototype with beats for pennies before committing to full buyouts. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "BeatLock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-synth-patchbook-10-x402 Title: PATCHWIRE · x402 Theme: Music & Sound Design (music) · sound design catalog Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A friction-less sound design vault where every 'Download' or 'Import to DAW' event is an atomic 0.01 USDC settlement. Synth-heads monetize their signal chains instantly, and producers buy specific presets without monthly subscriptions. Perfect for AI-driven music generators needing high-quality training samples or manual producers seeking the perfect oscillator sync. Why Hedera: By moving from high-friction licensing to per-patch micropayments, sound designers monetize the long tail of their library. x402 eliminates the 'cart' experience, turning preset browsing into a lean-in, pay-as-you-play experience. Market: TAM $1.2B — The global music production software and royalty-free sample industry. | SAM $240M — The digital plugin and preset market, shifting toward micro-licensing and granular sound access. | SOM $18M — The subset of synth enthusiasts and generative audio developers using Base for automated asset sourcing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PATCHWIRE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A friction-less sound design vault where every 'Download' or 'Import to DAW' event is an atomic 0.01 USDC settlement. Synth-heads monetize their signal chains instantly, and producers buy specific presets without monthly subscriptions. Perfect for AI-driven music generators needing high-quality training samples or manual producers seeking the perfect oscillator sync. Discipline: Music & Sound Design (sound design catalog). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from high-friction licensing to per-patch micropayments, sound designers monetize the long tail of their library. x402 eliminates the 'cart' experience, turning preset browsing into a lean-in, pay-as-you-play experience. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PATCHWIRE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-remix-rights-11-x402 Title: StemCell · x402 Theme: Music & Sound Design (music) · remix management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-derivative engine where remixers buy 'Creation Rights' via x402 signatures. Every time a stem is downloaded for a remix, 0.01 USDC is instantly streamed to the original creator. The facilitator settles the permission on-chain, returning a transaction hash that serves as the legal 'license to flip.' Logic gates the high-res audio exports behind micropayments, ensuring the ledger of derivatives is automated and paid. Why Hedera: Remixing is currently high-friction (manual clearance) or zero-value (bootlegs). By turning 'permission to remix' into a 0.01 USDC primitive, we monetize the long tail of bedroom producers and AI-sampling agents who need instant, verifiable rights. Market: TAM $2.4B — The global music synchronization and licensing market, shifted toward automated, high-volume micro-transactions. | SAM $180M — The secondary market for licensed stems, samples, and digital audio workstation (DAW) marketplace assets. | SOM $12M — Independent electronic music producers and AI-driven generative remix bots on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemCell" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-derivative engine where remixers buy 'Creation Rights' via x402 signatures. Every time a stem is downloaded for a remix, 0.01 USDC is instantly streamed to the original creator. The facilitator settles the permission on-chain, returning a transaction hash that serves as the legal 'license to flip.' Logic gates the high-res audio exports behind micropayments, ensuring the ledger of derivatives is automated and paid. Discipline: Music & Sound Design (remix management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Remixing is currently high-friction (manual clearance) or zero-value (bootlegs). By turning 'permission to remix' into a 0.01 USDC primitive, we monetize the long tail of bedroom producers and AI-sampling agents who need instant, verifiable rights. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemCell" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-loopswap-12-x402 Title: LoopVault · x402 Theme: Music & Sound Design (music) · loop exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity stem library where every 'Download' or 'Drag-to-DAW' action triggers a 0.05 USDC micropayment directly to the producer. By integrating x402, LoopVault removes the friction of monthly subscriptions and legal complexity. You pay exactly once per loop, instantly acquiring a cryptographically signed usage rights receipt tied to the Base transaction hash. It turns every sound into a metered asset for both human producers and AI music generators. Why Hedera: Traditional loop sites use predatory subscription models where creators get pennies. x402 enables 'Pay-Per-Stem' (PPS) utility, allowing bedroom producers to monetize individual sounds without a middleman taking 50%. It also solves the 'AI Training' problem by providing a technical gate for agents to legally ingest licensed audio one sample at a time. Market: TAM $2.8B — The global music production software and sample library market. | SAM $450M — The independent creator economy and sound design market adopting web3-native licensing. | SOM $12M — Initial sound designers and AI music model developers on Hedera seeking friction-less, legally-verifiable training data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LoopVault" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity stem library where every 'Download' or 'Drag-to-DAW' action triggers a 0.05 USDC micropayment directly to the producer. By integrating x402, LoopVault removes the friction of monthly subscriptions and legal complexity. You pay exactly once per loop, instantly acquiring a cryptographically signed usage rights receipt tied to the Base transaction hash. It turns every sound into a metered asset for both human producers and AI music generators. Discipline: Music & Sound Design (loop exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional loop sites use predatory subscription models where creators get pennies. x402 enables 'Pay-Per-Stem' (PPS) utility, allowing bedroom producers to monetize individual sounds without a middleman taking 50%. It also solves the 'AI Training' problem by providing a technical gate for agents to legally ingest licensed audio one sample at a time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LoopVault" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-patch-ledger-13-x402 Title: Sonic Trace · x402 Theme: Music & Sound Design (music) · sound patch tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographically verifiable lineage for sound designers. 0.01 USDC to mint a unique DNA signature for a patch, and 0.01 USDC for any producer to 'check out' the commercial license metadata. Every tweak, layer, and redistribution is a paid state change on Hedera, building a chain of custody that turns sound design into a high-frequency liquid asset. Why Hedera: Current licensing is opaque and slow. By turning 'licensing' into a 0.01 USDC API call, we enable DAWs and AI music generators to instantly verify and pay for the 'genetic' data of a sound at the point of synthesis. Market: TAM $2.8B — The global music production software and digital asset rights management market. | SAM $450M — The independent sound kit and sample pack market transitioning to on-chain distribution. | SOM $12M — Early-adopter synth programmers and VST developers on Hedera utilizing auto-licensing via HTS transfer. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sonic Trace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographically verifiable lineage for sound designers. 0.01 USDC to mint a unique DNA signature for a patch, and 0.01 USDC for any producer to 'check out' the commercial license metadata. Every tweak, layer, and redistribution is a paid state change on Hedera, building a chain of custody that turns sound design into a high-frequency liquid asset. Discipline: Music & Sound Design (sound patch tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current licensing is opaque and slow. By turning 'licensing' into a 0.01 USDC API call, we enable DAWs and AI music generators to instantly verify and pay for the 'genetic' data of a sound at the point of synthesis. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sonic Trace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-composer-collab-14-x402 Title: STEMSYNC · x402 Theme: Music & Sound Design (music) · composition sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless workspace for musicians to sync STEMs through a pay-per-pull version control system. Every commit cost $0.01 to pull, ensuring that session musicians, vocalists, and engineers are compensated for every iteration accessed by the lead producer. No subscriptions; you pay for the data you pipe into your DAW. Why Hedera: Moving from free collaboration to micro-metered access turns every 'Save' into a micro-royalty event, preventing 'creative theft' by ensuring collaborators are paid for access to their raw assets. Market: TAM $2.8B — The global music production software and collaborative tools market integrated with AI-agent stems. | SAM $450M — The growing market for remote session musicians and boutique sound libraries shifting to metered access. | SOM $12M — Independent electronic music producers and vocalists using Base for verifiable project tracking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEMSYNC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless workspace for musicians to sync STEMs through a pay-per-pull version control system. Every commit cost $0.01 to pull, ensuring that session musicians, vocalists, and engineers are compensated for every iteration accessed by the lead producer. No subscriptions; you pay for the data you pipe into your DAW. Discipline: Music & Sound Design (composition sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from free collaboration to micro-metered access turns every 'Save' into a micro-royalty event, preventing 'creative theft' by ensuring collaborators are paid for access to their raw assets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEMSYNC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-sound-nft-15-x402 Title: SonicStream · x402 Theme: Music & Sound Design (music) · audio collectibles Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-listen protocol for high-fidelity stems and sound packs. Users sign a 0.01 USDC HTS transfer permit to instantly stream a track or download a sample, with royalties auto-routed to the creator's wallet. Forget gas-heavy minting; this is granular, real-time audio consumption where every play is a settled transaction on Hedera. Why Hedera: Traditional Music NFTs suffer from high friction and high unit prices ($20+). x402 allows for 'metered listening' and 'fragmented licensing'—where a producer pays a penny to preview a high-quality stem, turning the entire catalog into a liquid, pay-per-use sound library. Market: TAM $26.3B — The global recorded music industry transitioning to friction-less, micro-monetized streaming architectures. | SAM $450M — The global digital music licensing and sample pack market moving toward micro-transactions. | SOM $12M — Web3 native producers and AI-agent music generators needing programmatic access to licensed stems. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SonicStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-listen protocol for high-fidelity stems and sound packs. Users sign a 0.01 USDC HTS transfer permit to instantly stream a track or download a sample, with royalties auto-routed to the creator's wallet. Forget gas-heavy minting; this is granular, real-time audio consumption where every play is a settled transaction on Hedera. Discipline: Music & Sound Design (audio collectibles). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional Music NFTs suffer from high friction and high unit prices ($20+). x402 allows for 'metered listening' and 'fragmented licensing'—where a producer pays a penny to preview a high-quality stem, turning the entire catalog into a liquid, pay-per-use sound library. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SonicStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-samplechain-pro-16-x402 Title: Provenance · x402 Theme: Music & Sound Design (music) · sample provenance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A forensic audio engine for the agent-economy. Pay 0.01 USDC to instantly verify the complete provenance and rights-chain of any audio snippet. No subscriptions—producers and AI training scrapers pay per fingerprint checked to ensure zero-liability sampling. Settles on Hedera with an immutable proof-of-license hash. Why Hedera: By shifting from a 'Pro' subscription to a per-check micro-fee, the tool becomes an API primitive for DAW plugins and automated AI music generators that need to clear rights in real-time. Market: TAM $4.2B — The global music sampling, licensing, and sync-rights industry. | SAM $850M — The emerging licensing market for AI music training datasets and real-time streaming royalties. | SOM $12M — Web3-native producers and autonomous music agents operating on Hedera and Zora. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Provenance" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A forensic audio engine for the agent-economy. Pay 0.01 USDC to instantly verify the complete provenance and rights-chain of any audio snippet. No subscriptions—producers and AI training scrapers pay per fingerprint checked to ensure zero-liability sampling. Settles on Hedera with an immutable proof-of-license hash. Discipline: Music & Sound Design (sample provenance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a 'Pro' subscription to a per-check micro-fee, the tool becomes an API primitive for DAW plugins and automated AI music generators that need to clear rights in real-time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Provenance" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-liveloop-archive-17-x402 Title: MasterStem · x402 Theme: Music & Sound Design (music) · live set recording Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-trace live set archival. Performers trigger a 0.01 USDC x402 call to timestamp and immutable-store a loop buffer or stem directly from their DAW. Fans and producers pay 0.01 USDC to unlock high-fidelity stems for remixes, with immediate settlement to the artist. Total provenance for the modular age. Why Hedera: Traditional cloud storage is a subscription sunk cost; x402 turns every 'Save' and 'Export' into a micro-transactional proof of creation, enabling a pay-as-you-go marketplace for raw live elements. Market: TAM $2.4B — The global Creator Economy and Digital Asset Management market. | SAM $480M — The electronic music production and licensing market adopting on-chain provenance. | SOM $12M — DAU of Ableton/Logic users integrating micro-payment export plugins on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MasterStem" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-trace live set archival. Performers trigger a 0.01 USDC x402 call to timestamp and immutable-store a loop buffer or stem directly from their DAW. Fans and producers pay 0.01 USDC to unlock high-fidelity stems for remixes, with immediate settlement to the artist. Total provenance for the modular age. Discipline: Music & Sound Design (live set recording). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional cloud storage is a subscription sunk cost; x402 turns every 'Save' and 'Export' into a micro-transactional proof of creation, enabling a pay-as-you-go marketplace for raw live elements. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MasterStem" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-soundpatch-market-18-x402 Title: OSCILLATE · x402 Theme: Music & Sound Design (music) · preset marketplace Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular distribution protocol for sound design. Stop buying bloated $99 packs; pay 0.01 USDC to instantly preview and unlock the raw JSON/sysex data for a single Serum, Vital, or FM8 preset directly into your DAW. Creators receive real-time, micro-royalties every time their patch is 'pulled' by a producer or an automated AI composition agent. Payment is the unlock: no carts, no accounts, just signature-to-sound. Why Hedera: Traditional preset stores suffer from high friction and piracy. x402 enables 'atomic sound design' where the cost of a single patch is negligible to the user but creates a high-volume revenue stream for creators, perfectly suited for autonomous agents generating music who need to 'buy' timbre on the fly. Market: TAM $2.8B — The creator economy for music production and independent sound design. | SAM $420M — The global virtual instrument and plugin expansion market. | SOM $12M — Micro-licensing for individual synth presets and AI-music generation training data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "OSCILLATE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular distribution protocol for sound design. Stop buying bloated $99 packs; pay 0.01 USDC to instantly preview and unlock the raw JSON/sysex data for a single Serum, Vital, or FM8 preset directly into your DAW. Creators receive real-time, micro-royalties every time their patch is 'pulled' by a producer or an automated AI composition agent. Payment is the unlock: no carts, no accounts, just signature-to-sound. Discipline: Music & Sound Design (preset marketplace). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional preset stores suffer from high friction and piracy. x402 enables 'atomic sound design' where the cost of a single patch is negligible to the user but creates a high-volume revenue stream for creators, perfectly suited for autonomous agents generating music who need to 'buy' timbre on the fly. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "OSCILLATE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-genre-mapper-19-x402 Title: SONIC TAGGER · x402 Theme: Music & Sound Design (music) · music classification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular music tagging engine for high-velocity streaming and DJ libraries. Pay 0.01 USDC per track to instantly inject EBU R128 loudness data, BPM, key, and multidimensional genre tags into a file's on-chain metadata. Forget broad labels; purchase precision classification for automated curation. Why Hedera: Music classification is a high-compute task often locked behind monthly subscriptions. By moving to x402, we enable 'per-track' billing, allowing indie DJs to tag small crates or AI agents to categorize massive royalty-free libraries at a fixed, micro-scale cost without platform lock-in. Market: TAM $4.2B — The global music streaming and metadata services market. | SAM $850M — The digital DJ software and music library management market. | SOM $12M — Independent curators and AI playlist agents on Hedera utilizing automated metadata enrichment. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SONIC TAGGER" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular music tagging engine for high-velocity streaming and DJ libraries. Pay 0.01 USDC per track to instantly inject EBU R128 loudness data, BPM, key, and multidimensional genre tags into a file's on-chain metadata. Forget broad labels; purchase precision classification for automated curation. Discipline: Music & Sound Design (music classification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Music classification is a high-compute task often locked behind monthly subscriptions. By moving to x402, we enable 'per-track' billing, allowing indie DJs to tag small crates or AI agents to categorize massive royalty-free libraries at a fixed, micro-scale cost without platform lock-in. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SONIC TAGGER" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-audio-blueprint-20-x402 Title: Sonic State · x402 Theme: Music & Sound Design (music) · sound design templates Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity sound design vault where every template 'pull' is a micro-settlement. Designers publish reusable DAW racks, synth chains, and spatial spatial templates. Users pay 0.01 USDC to unlock a specific version or branch, with 100% of the fee routed to the verified author via HTS transfer. Payment is the atomic unit of provenance, ensuring authorship is cryptographically tied to every download. Why Hedera: Traditional marketplaces have too much friction for single-asset downloads (high fees, cart flows). x402 enables a 'Pay-per-Preset' model that allows sound designers to monetize granular templates instantly without a subscription. Market: TAM $2.1B — The global music production software and digital asset ecosystem. | SAM $420M — Decentralized sound design community and DAW-integrated asset markets. | SOM $18M — Pro sound designers and game audio engineers on Hedera utilizing automated licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sonic State" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity sound design vault where every template 'pull' is a micro-settlement. Designers publish reusable DAW racks, synth chains, and spatial spatial templates. Users pay 0.01 USDC to unlock a specific version or branch, with 100% of the fee routed to the verified author via HTS transfer. Payment is the atomic unit of provenance, ensuring authorship is cryptographically tied to every download. Discipline: Music & Sound Design (sound design templates). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional marketplaces have too much friction for single-asset downloads (high fees, cart flows). x402 enables a 'Pay-per-Preset' model that allows sound designers to monetize granular templates instantly without a subscription. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sonic State" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-loopchain-sync-21-x402 Title: RiffStack · x402 Theme: Music & Sound Design (music) · collaborative looping Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless DAW for permanent collaborative jams. Every time you commit a loop, add a filter, or layer a synth, you pay 0.01 USDC. This micropayment covers the Arweave/Filecoin storage gas and triggers a real-time broadcast to all peers. Version history is a chain of paid events; to fork a session, developers or artists pay to 'pull' the state. Pay-per-sync ensures only the best takes reach the master track. Why Hedera: Moving from 'free sync' to 'pay-per-edit' solves the 'noise' problem in open collaboration while instantly compensating the infrastructure costs for permanent decentralized storage. Market: TAM $1.8B — Global digital audio workstation and music collaboration software market. | SAM $240M — The growing market for 'Prosumer' music apps and collaborative modular gear (Splice, Ableton Cloud). | SOM $9.5M — Niche creative technologists and crypto-native sound designers operating in DAO-based music collectives. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RiffStack" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless DAW for permanent collaborative jams. Every time you commit a loop, add a filter, or layer a synth, you pay 0.01 USDC. This micropayment covers the Arweave/Filecoin storage gas and triggers a real-time broadcast to all peers. Version history is a chain of paid events; to fork a session, developers or artists pay to 'pull' the state. Pay-per-sync ensures only the best takes reach the master track. Discipline: Music & Sound Design (collaborative looping). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'free sync' to 'pay-per-edit' solves the 'noise' problem in open collaboration while instantly compensating the infrastructure costs for permanent decentralized storage. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RiffStack" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-dynamic-scores-22-x402 Title: InkStream · x402 Theme: Music & Sound Design (music) · interactive notation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized canvas for interactive musical manuscripts where every 'read' of a dynamic layer or playback of an embedded MIDI-stem executes a 0.01 USDC micro-settlement to the composer via x402. Transition from static PDFs to 'Performance-as-a-Service' where educators pay-per-measure and performers unlock branching paths in real-time. Why Hedera: Traditional sheet music suffers from bulk licensing and piracy. x402 transforms notation into a metered API. By charging per measure-unlocked or per instrument-toggle, it aligns the cost of study and performance with actual usage, providing composers with immediate, recurring revenue. Market: TAM $4.5B — Global music publishing and instructional materials market. | SAM $280M — The digital sheet music and music education software market. | SOM $12M — Web3-native composers, avant-garde performers, and conservatories utilizing Base for verifiable digital assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized canvas for interactive musical manuscripts where every 'read' of a dynamic layer or playback of an embedded MIDI-stem executes a 0.01 USDC micro-settlement to the composer via x402. Transition from static PDFs to 'Performance-as-a-Service' where educators pay-per-measure and performers unlock branching paths in real-time. Discipline: Music & Sound Design (interactive notation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional sheet music suffers from bulk licensing and piracy. x402 transforms notation into a metered API. By charging per measure-unlocked or per instrument-toggle, it aligns the cost of study and performance with actual usage, providing composers with immediate, recurring revenue. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-stem-provenance-23-x402 Title: STEMSOURCE · x402 Theme: Music & Sound Design (music) · multitrack tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity multitrack repository where DAW users pay 0.01 USDC to pull, push, or verify a single stem. Every track bounce is a signed HTS transfer transaction, embedding cryptographic provenance directly into the layer of exchange. Eliminate license ambiguity by making the payment the proof of origin. Why Hedera: By moving from 'storage' to 'pay-per-interaction,' we turn provenance into a real-time ledger. Producers don't just 'tag' stems; they authorize them via micropayments, ensuring every layer in a project has a settled financial and data footprint on Hedera. Market: TAM $4.2B — Global music production software market transitioning to cloud-sync and collaborative ecosystems. | SAM $280M — Web3-native producers and collaborative remote studios using Hedera testnet for session management. | SOM $12M — Independent sound designers and boutique sample label creators requiring per-stem verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEMSOURCE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity multitrack repository where DAW users pay 0.01 USDC to pull, push, or verify a single stem. Every track bounce is a signed HTS transfer transaction, embedding cryptographic provenance directly into the layer of exchange. Eliminate license ambiguity by making the payment the proof of origin. Discipline: Music & Sound Design (multitrack tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'storage' to 'pay-per-interaction,' we turn provenance into a real-time ledger. Producers don't just 'tag' stems; they authorize them via micropayments, ensuring every layer in a project has a settled financial and data footprint on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEMSOURCE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-gasless-remix-exchange-0-x402 Title: StemStream · x402 Theme: Music & Sound Design (music) · collaborative remixing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless MPC mixer where every stem interaction is a micro-settlement. Users sign 0.01 USDC payloads to unlock access to a high-fidelity stem, trigger an AI-mastering pass, or commit a new layer to the master session. No gas, just raw sound currency. Why Hedera: By replacing 'gasless' (which implies free) with 'pay-per-stem' x402, we turn a collaboration hobby into a micro-economic marketplace. The friction of payment is negated by the HTS transfer signature, making 'paying' as fast as 'clicking.' Market: TAM $4.5B — The global digital music collaboration and DAW software market. | SAM $840M — The independent music production and sample pack sector adopting programmable ownership. | SOM $12M — Web3-native producers and DAW-integrated agent-collaborators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless MPC mixer where every stem interaction is a micro-settlement. Users sign 0.01 USDC payloads to unlock access to a high-fidelity stem, trigger an AI-mastering pass, or commit a new layer to the master session. No gas, just raw sound currency. Discipline: Music & Sound Design (collaborative remixing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing 'gasless' (which implies free) with 'pay-per-stem' x402, we turn a collaboration hobby into a micro-economic marketplace. The friction of payment is negated by the HTS transfer signature, making 'paying' as fast as 'clicking.' 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-sound-packs-1-x402 Title: Sonic Ledger · x402 Theme: Music & Sound Design (music) · sample library distribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-sample auditioning. Kill the upfront $50 pack model. Use x402 to stream high-fidelity WAV previews that auto-unlock the full STEM and royalty license upon a signed 0.01 USDC micro-settlement. DAW plugins call the contract directly, allowing producers to 'drag-and-drop' sounds into their timeline with zero friction and instant onchain attribution. Why Hedera: Traditional libraries force users to buy hundreds of sounds to get the two they want. By metering the library at the granular sample level, producers save money while creators capture massive volume from AI-music generators and individual bedroom producers who trade friction for speed. Market: TAM $1.2B — The total digital music creation software and soundware market. | SAM $210M — The global royalty-free sample and loop market (Splice, Output, etc.). | SOM $2.4M — Base-native music producers and AI music agents performing high-frequency sample retrieval. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sonic Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-sample auditioning. Kill the upfront $50 pack model. Use x402 to stream high-fidelity WAV previews that auto-unlock the full STEM and royalty license upon a signed 0.01 USDC micro-settlement. DAW plugins call the contract directly, allowing producers to 'drag-and-drop' sounds into their timeline with zero friction and instant onchain attribution. Discipline: Music & Sound Design (sample library distribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional libraries force users to buy hundreds of sounds to get the two they want. By metering the library at the granular sample level, producers save money while creators capture massive volume from AI-music generators and individual bedroom producers who trade friction for speed. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sonic Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-nft-beat-marketplace-2-x402 Title: Rhythm Meter · x402 Theme: Music & Sound Design (music) · beat selling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Unlock the studio vault. Instead of bloated $500 exclusive licenses, high-fidelity stems and master tracks are metered at the sample level. Sign once with your Magic Link email sign-in and pay 0.01 USDC to preview a high-quality loop, 0.05 to download a MIDI pattern, or 0.10 to stream the full stem-set. Every 'play' and 'download' is a micro-transaction settled instantly on Hedera, giving producers a real-time revenue stream and rappers a friction-less, pay-as-you-cook workflow. No carts, no checkout, just flow. Why Hedera: Moving from high-friction NFT purchases to low-friction usage-based payments transforms beats from 'assets to be hoarded' into 'utilities to be consumed.' The x402 primitive allows for granular pricing (pay-per-stem) which is impossible with traditional card payments or gas-intensive mints. Market: TAM $1.5B — Global beat-selling and stock audio market transitioning to automated, high-velocity micropayments. | SAM $120M — Emerging onchain creators and independent rappers using micro-licensing. | SOM $8M — Initial Base/HashPack power users and bedroom producers transitioning from subscription models. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Rhythm Meter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Unlock the studio vault. Instead of bloated $500 exclusive licenses, high-fidelity stems and master tracks are metered at the sample level. Sign once with your Magic Link email sign-in and pay 0.01 USDC to preview a high-quality loop, 0.05 to download a MIDI pattern, or 0.10 to stream the full stem-set. Every 'play' and 'download' is a micro-transaction settled instantly on Hedera, giving producers a real-time revenue stream and rappers a friction-less, pay-as-you-cook workflow. No carts, no checkout, just flow. Discipline: Music & Sound Design (beat selling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from high-friction NFT purchases to low-friction usage-based payments transforms beats from 'assets to be hoarded' into 'utilities to be consumed.' The x402 primitive allows for granular pricing (pay-per-stem) which is impossible with traditional card payments or gas-intensive mints. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Rhythm Meter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-instant-royalty-splits-3-x402 Title: SplitStream · x402 Theme: Music & Sound Design (music) · music rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Direct-to-collaborator stems. Listeners or sync agents pay $0.01 USDC per play/download to instantly trigger a multi-sig revenue split via HTS transfer. No more monthly accounting; every micro-interaction is a settlement event. Why Hedera: Traditional royalty management suffers from 'the long tail' problem where small amounts are trapped in legacy systems due to high payout thresholds. x402 enables sub-cent precision, turning a single playback into a real-time dividend. Market: TAM $26B — The global music recording and publishing industry shifting toward transparent streaming. | SAM $1.4M — Base-native music platforms and indie labels seeking automated overhead reduction. | SOM $350K — High-frequency sync-licensing marketplaces for social media content creators. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SplitStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Direct-to-collaborator stems. Listeners or sync agents pay $0.01 USDC per play/download to instantly trigger a multi-sig revenue split via HTS transfer. No more monthly accounting; every micro-interaction is a settlement event. Discipline: Music & Sound Design (music rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional royalty management suffers from 'the long tail' problem where small amounts are trapped in legacy systems due to high payout thresholds. x402 enables sub-cent precision, turning a single playback into a real-time dividend. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SplitStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-gas-free-live-sampling-4-x402 Title: RIPSTATE · x402 Theme: Music & Sound Design (music) · live-set sampling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Convert live room sound into instant, permissionless audio stems. Performers drop a mic in a club; fans pay 0.05 USDC to 'Rip' the current 4-bar loop directly to their wallet. Each rip triggers an x402 stream that compensates the performer, the venue, and the sound engineer in real-time. No gas, just raw sound extraction at the speed of the beat. Why Hedera: By shifting from 'gas-free' to 'pay-per-rip,' the value moves from the technicality of the chain to the scarcity of the moment. HTS transfer allows fans to capture live high-fidelity samples during a set without breaking their flow by signing a wallet pop-up. Market: TAM $1.2B — The global live music and creator economy integration market. | SAM $95M — The sampled audio licensing and royalty market for independent electronic music producers. | SOM $4.2M — Live performance sampling and 'bootleg' culture revenue on Hedera testnet via micro-transactions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RIPSTATE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Convert live room sound into instant, permissionless audio stems. Performers drop a mic in a club; fans pay 0.05 USDC to 'Rip' the current 4-bar loop directly to their wallet. Each rip triggers an x402 stream that compensates the performer, the venue, and the sound engineer in real-time. No gas, just raw sound extraction at the speed of the beat. Discipline: Music & Sound Design (live-set sampling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'gas-free' to 'pay-per-rip,' the value moves from the technicality of the chain to the scarcity of the moment. HTS transfer allows fans to capture live high-fidelity samples during a set without breaking their flow by signing a wallet pop-up. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RIPSTATE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-social-jam-sessions-5-x402 Title: Resonance · x402 Theme: Music & Sound Design (music) · online collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time low-latency collaborative DAW where every stems-sync and track-render is a micro-transaction. Pay 0.01 USDC to 'Open Mic' for 60 seconds of global audio-in, or meter your session to pay-per-loop. Musicians earn instantly as collaborators 'unlock' specific layers of a jam. Session logs are settled on-chain via HTS transfer, turning collaborative creative energy into a metered economy. Why Hedera: By moving from a subscription model to a pay-per-buffer-sync model, the app eliminates the 'leech' problem in jams. Creators are incentivized to provide high-quality input because every unlock by a peer results in an instant USDC settlement. It turns jam sessions into micro-marketplaces for loops and riffs. Market: TAM $4.8B — The global online music collaboration and production software market. | SAM $1.2B — Indie producers, session musicians, and bedroom creators using web-based DAWs. | SOM $45M — On-chain power users and generative music agents requiring real-time high-fidelity collaboration. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Resonance" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time low-latency collaborative DAW where every stems-sync and track-render is a micro-transaction. Pay 0.01 USDC to 'Open Mic' for 60 seconds of global audio-in, or meter your session to pay-per-loop. Musicians earn instantly as collaborators 'unlock' specific layers of a jam. Session logs are settled on-chain via HTS transfer, turning collaborative creative energy into a metered economy. Discipline: Music & Sound Design (online collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a subscription model to a pay-per-buffer-sync model, the app eliminates the 'leech' problem in jams. Creators are incentivized to provide high-quality input because every unlock by a peer results in an instant USDC settlement. It turns jam sessions into micro-marketplaces for loops and riffs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Resonance" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-curated-sound-trails-6-x402 Title: Sonic Ledger · x402 Theme: Music & Sound Design (music) · music curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity audit trail for sonic discovery. Listeners pay 0.01 USDC to 'unlock' the next track in a curated queue, instantly routing the micropayment to the curator's wallet. Curation quality is enforced by skin-in-the-game: curators stake to list, and listeners pay-per-stream to validate taste. No subscriptions, just a direct value-link between sound selection and audience ear. Why Hedera: By moving from 'free playlists' to x402-metered 'Sound Trails,' curation becomes a provable professional service. The low friction of 0.01 USDC via the embedded wallet allows for impulse consumption while solving the streaming royalty gap for niche tastemakers. Market: TAM $26B — Global music streaming and discovery market transitioning to micro-monetization. | SAM $850M — The addressable market for independent music curators, sync agents, and boutique radio onchain. | SOM $12M — Onchain audiophiles and Base-native users seeking human-filtered sound over algorithmic noise. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sonic Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity audit trail for sonic discovery. Listeners pay 0.01 USDC to 'unlock' the next track in a curated queue, instantly routing the micropayment to the curator's wallet. Curation quality is enforced by skin-in-the-game: curators stake to list, and listeners pay-per-stream to validate taste. No subscriptions, just a direct value-link between sound selection and audience ear. Discipline: Music & Sound Design (music curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'free playlists' to x402-metered 'Sound Trails,' curation becomes a provable professional service. The low friction of 0.01 USDC via the embedded wallet allows for impulse consumption while solving the streaming royalty gap for niche tastemakers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sonic Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-authentic-sample-provenance-7-x402 Title: Provenance · x402 Theme: Music & Sound Design (music) · sample authentication Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-pull hardware-verified stems. Stop forensic lawsuits before they start by embedding cryptographic proof of origin into every file download. the embedded wallet-signed micropayments allow DAWs to instantly 'unlock' legal clearance for a single loop or drum hit without monthly subscriptions. Why Hedera: By shifting from 'verification' to 'pay-per-unlock clearance,' the transaction becomes the legal receipt. x402 allows for granular licensing (metered usage) rather than all-or-nothing subscriptions, making high-end samples accessible to bedroom producers while ensuring creators are paid for every individual use. Market: TAM $2.8B — The global music sample, loop, and sound design market transitioning to automated DRM and AI-agent consumption. | SAM $450M — The digital music production and royalty clearance market looking for frictionless, micro-licensing solutions. | SOM $12M — Base-native producers and Web3 music platforms requiring instant, compliant sample-clearing for onchain releases. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Provenance" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-pull hardware-verified stems. Stop forensic lawsuits before they start by embedding cryptographic proof of origin into every file download. the embedded wallet-signed micropayments allow DAWs to instantly 'unlock' legal clearance for a single loop or drum hit without monthly subscriptions. Discipline: Music & Sound Design (sample authentication). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'verification' to 'pay-per-unlock clearance,' the transaction becomes the legal receipt. x402 allows for granular licensing (metered usage) rather than all-or-nothing subscriptions, making high-end samples accessible to bedroom producers while ensuring creators are paid for every individual use. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Provenance" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-synth-presets-8-x402 Title: OSCILLATE · x402 Theme: Music & Sound Design (music) · sound design presets Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity library of professional synth patches and modular racks where payment is the audition. 0.01 USDC triggers an instant secure transmission of the .syx or plugin-specific file to your wallet. Skip the subscriptions; pay only for the textures that make the final mix. Built for human sound designers and music-generative AI agents requiring high-quality timbre seeds. Why Hedera: By shifting from a storefront model to a pay-per-download primitive, we eliminate the friction of 'bundle buying.' Musicians often need one specific lead or bass; x402 turns every sound into a liquid, micro-priced asset compatible with automated DAW workflows. Market: TAM $2.4B — The global music production software and royalty-free content market. | SAM $850M — The projected market for digital audio workstations and plugin eco-systems by 2027. | SOM $12M — Revenue captured from independent sound designers and AI music engines utilizing per-patch micro-licensing on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "OSCILLATE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity library of professional synth patches and modular racks where payment is the audition. 0.01 USDC triggers an instant secure transmission of the .syx or plugin-specific file to your wallet. Skip the subscriptions; pay only for the textures that make the final mix. Built for human sound designers and music-generative AI agents requiring high-quality timbre seeds. Discipline: Music & Sound Design (sound design presets). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a storefront model to a pay-per-download primitive, we eliminate the friction of 'bundle buying.' Musicians often need one specific lead or bass; x402 turns every sound into a liquid, micro-priced asset compatible with automated DAW workflows. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "OSCILLATE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-gasless-beat-battles-9-x402 Title: DropState · x402 Theme: Music & Sound Design (music) · music competitions Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes per-vote leaderboard where every interaction is a $0.01 injection into the prize pool. Producers deposit tracks; listeners pay a micro-USDC fee to trigger a 'Vote' or 'Critique' action. The x402 primitive eliminates gas-friction while ensuring every vote has skin in the game. Winners are settled instantly via the facilitator at the battle's end. Why Hedera: Moving from 'gasless' to 'micro-paid' transforms passive voting into an active revenue engine. By making votes cost $0.01 USDC, you filter out bot-spam and build a real-time bounty for artists. x402 handles the high-frequency micro-transactions that would be impossible with traditional gas or credit card fees. Market: TAM $4.2B — The global music competition and talent scouting industry, shifting toward decentralized, automated payouts. | SAM $120M — Emerging market for onchain creator tools and micro-incentivized social music platforms. | SOM $8.5M — Niche electronic music and hip-hop beat-making communities using Base for low-cost settlement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DropState" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes per-vote leaderboard where every interaction is a $0.01 injection into the prize pool. Producers deposit tracks; listeners pay a micro-USDC fee to trigger a 'Vote' or 'Critique' action. The x402 primitive eliminates gas-friction while ensuring every vote has skin in the game. Winners are settled instantly via the facilitator at the battle's end. Discipline: Music & Sound Design (music competitions). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'gasless' to 'micro-paid' transforms passive voting into an active revenue engine. By making votes cost $0.01 USDC, you filter out bot-spam and build a real-time bounty for artists. x402 handles the high-frequency micro-transactions that would be impossible with traditional gas or credit card fees. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DropState" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-collaborative-loop-vault-10-x402 Title: STEMS · x402 Theme: Music & Sound Design (music) · loop sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-sample. A headless library where every high-fidelity stem and drum loop is an atomic HTS transfer payment away. Producers sign a 1-cent signature to instantly download or fork a sound, bypassing subscription fatigue. Creators earn real-time streaming royalties as their sound-bites are pulled into DAWs via API, creating a high-velocity liquidity layer for sound design. Why Hedera: Traditional loop sites use credit systems to hide costs; x402 exposes the true micro-value of a sample. By making the payment the 'GET' request, it enables DAWs and AI music agents to programmatically buy and sequence sounds without manual checkout processes. Market: TAM $1.4B (The global music production software and digital content creation market). | SAM $280M (Focusing on the growing market of independent 'bedroom' producers and AI-driven music generation tools requiring licensed training data/stems). | SOM $12M (Initial capture of high-frequency sample flippers and developers building AI music plugins on Hedera). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEMS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-sample. A headless library where every high-fidelity stem and drum loop is an atomic HTS transfer payment away. Producers sign a 1-cent signature to instantly download or fork a sound, bypassing subscription fatigue. Creators earn real-time streaming royalties as their sound-bites are pulled into DAWs via API, creating a high-velocity liquidity layer for sound design. Discipline: Music & Sound Design (loop sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional loop sites use credit systems to hide costs; x402 exposes the true micro-value of a sample. By making the payment the 'GET' request, it enables DAWs and AI music agents to programmatically buy and sequence sounds without manual checkout processes. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEMS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-transparent-sample-licensing-11-x402 Title: STEMS · x402 Theme: Music & Sound Design (music) · sample rights Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity sound library where every 'Download' or 'Drag-and-Drop' is a 0.01 USDC primitive. Eliminate bulky subscription tiers and legal ambiguity. Producers pay per-snare or per-loop via a signature; the x402 facilitator handles the license issuance on Hedera. The HTS transfer signature acts as the timestamped proof-of-rights, instantly settling royalty splits to the original sound designer. Why Hedera: By turning the 'license' into a sub-penny transaction, we remove the friction of traditional sample packs where users pay $30 for 100 sounds they don't want. The x402 model enables 'surgical sourcing' for DAW users. Market: TAM $2.6B — The total creator economy segment for digital licensing and sound design infrastructure. | SAM $840M — The global digital music production and sample pack market. | SOM $12M — Independent bedroom producers and AI-generative music agents using granular, per-use assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEMS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity sound library where every 'Download' or 'Drag-and-Drop' is a 0.01 USDC primitive. Eliminate bulky subscription tiers and legal ambiguity. Producers pay per-snare or per-loop via a signature; the x402 facilitator handles the license issuance on Hedera. The HTS transfer signature acts as the timestamped proof-of-rights, instantly settling royalty splits to the original sound designer. Discipline: Music & Sound Design (sample rights). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the 'license' into a sub-penny transaction, we remove the friction of traditional sample packs where users pay $30 for 100 sounds they don't want. The x402 model enables 'surgical sourcing' for DAW users. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEMS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-instant-collab-contracts-12-x402 Title: SplitTap · x402 Theme: Music & Sound Design (music) · agreement automation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A frictionless 'pay-to-play' handshake for musicians. Producers and vocalists lock split-sheet terms by signing a 0.01 USDC x402 transaction. The payment is the signature—the moment the micro-cent clears, the legal agreement is hashed to Base. No gas tokens, no legalese, just a sub-cent trigger that automates royalty routing. Why Hedera: By turning the legal 'agreement' into a low-friction micropayment, we bypass the need for traditional dApp onboarding. The signature is the settlement. This leverages HTS transfer to ensure that even users without ETH can commit to legally binding onchain terms via a signed intent. Market: TAM $1.8B — Global music rights management and automated contract licensing market. | SAM $220M — Focused on the independent creator economy and bedroom producers using DAWs like Ableton and FL Studio. | SOM $15M — Early adopters in the 'Type Beat' and 'Splice' ecosystem looking for instant copyright verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SplitTap" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A frictionless 'pay-to-play' handshake for musicians. Producers and vocalists lock split-sheet terms by signing a 0.01 USDC x402 transaction. The payment is the signature—the moment the micro-cent clears, the legal agreement is hashed to Base. No gas tokens, no legalese, just a sub-cent trigger that automates royalty routing. Discipline: Music & Sound Design (agreement automation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the legal 'agreement' into a low-friction micropayment, we bypass the need for traditional dApp onboarding. The signature is the settlement. This leverages HTS transfer to ensure that even users without ETH can commit to legally binding onchain terms via a signed intent. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SplitTap" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-gasless-sound-design-nft-13-x402 Title: Sonic Meter · x402 Theme: Music & Sound Design (music) · NFT sound assets Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-sample. Every play is a micro-license. Every export is a settlement. Build a sound library where users pay 1 cent per high-fidelity stem preview, bypassing the friction of subscription models. Producers earn instantly as their sounds are pulled into DAW logic via HTS transfer auth. No gas, just raw sonic commerce. Why Hedera: By shifting from 'Gasless NFTs' to 'Pay-per-Listen/Use', we turn passive sound assets into an active revenue stream. Users are more likely to spend $0.01 to test a kick drum in their mix than $20 for a whole pack they won't use. Market: TAM $1.8B — The total creator economy for digital audio assets and AI-generated music training data. | SAM $240M — The global royalty-free sample and loop market (Splice, Loopmasters) transitioning to granular, per-use billing. | SOM $12M — Independent sound designers and bedroom producers on Hedera seeking instant gratification micro-sales. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sonic Meter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-sample. Every play is a micro-license. Every export is a settlement. Build a sound library where users pay 1 cent per high-fidelity stem preview, bypassing the friction of subscription models. Producers earn instantly as their sounds are pulled into DAW logic via HTS transfer auth. No gas, just raw sonic commerce. Discipline: Music & Sound Design (NFT sound assets). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'Gasless NFTs' to 'Pay-per-Listen/Use', we turn passive sound assets into an active revenue stream. Users are more likely to spend $0.01 to test a kick drum in their mix than $20 for a whole pack they won't use. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sonic Meter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-mix-feedback-14-x402 Title: PulseCheck · x402 Theme: Music & Sound Design (music) · mix critique Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular 'Pay-per-Critique' engine for mixing engineers. Producers pay 0.01 USDC to unlock timestamped audio comments from verified sound designers. No subscriptions; pay only for the feedback you use. Each comment is an HTS transfer signed transaction, ensuring the critic is paid instantly upon submission. Ideal for AI-agent mastering bots and human engineers alike. Why Hedera: By turning feedback into a micro-commodity, we solve the 'low-effort comment' problem. Each critique carries a cost, ensuring skin in the game for the listener and instant liquidity for the professional. It shifts mix review from a social favor to an on-chain professional service. Market: TAM $1.2B — The global music production and software plugin market transitioning to API-metered services. | SAM $130M — Mixing/Mastering services accessible via micropayment-enabled web3 platforms. | SOM $12M — Independent bedroom producers and AI-mastering agents seeking human-in-the-loop verification on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PulseCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular 'Pay-per-Critique' engine for mixing engineers. Producers pay 0.01 USDC to unlock timestamped audio comments from verified sound designers. No subscriptions; pay only for the feedback you use. Each comment is an HTS transfer signed transaction, ensuring the critic is paid instantly upon submission. Ideal for AI-agent mastering bots and human engineers alike. Discipline: Music & Sound Design (mix critique). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning feedback into a micro-commodity, we solve the 'low-effort comment' problem. Each critique carries a cost, ensuring skin in the game for the listener and instant liquidity for the professional. It shifts mix review from a social favor to an on-chain professional service. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PulseCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-gasless-sample-swap-15-x402 Title: SONIK · x402 Theme: Music & Sound Design (music) · sample exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn every one-shot, loop, and MIDI sequence into a sub-cent revenue stream. Producers pay $0.01 per high-res download or sequence preview using instant HTS transfer signing. Bypass subscription fatigue and bloated marketplaces. You don't 'swap'—you stream micro-royalties directly to the creator's wallet with every drag-and-drop. Settlement is instant on Hedera, turning your sample library into a self-monetizing API for other artists and AI music agents. Why Hedera: The 'gasless swap' model is replaced by a high-velocity 'pay-per-pull' model. By pricing at $0.01, we remove the friction of large purchases while providing a direct financial incentive for quality uploads. The x402 primitive turns social sign-in into a payment gateway. Market: TAM $1.8B — The global music production software and sample library industry. | SAM $240M — The independent producer & bedroom beatmaker market (Splice/Output users). | SOM $12M — Web3-native sound designers and AI agent developers sourcing training data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SONIK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn every one-shot, loop, and MIDI sequence into a sub-cent revenue stream. Producers pay $0.01 per high-res download or sequence preview using instant HTS transfer signing. Bypass subscription fatigue and bloated marketplaces. You don't 'swap'—you stream micro-royalties directly to the creator's wallet with every drag-and-drop. Settlement is instant on Hedera, turning your sample library into a self-monetizing API for other artists and AI music agents. Discipline: Music & Sound Design (sample exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: The 'gasless swap' model is replaced by a high-velocity 'pay-per-pull' model. By pricing at $0.01, we remove the friction of large purchases while providing a direct financial incentive for quality uploads. The x402 primitive turns social sign-in into a payment gateway. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SONIK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-live-set-proof-16-x402 Title: HEARTHASH · x402 Theme: Music & Sound Design (music) · performance verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-attestation protocol for touring musicians. Every track played or gear-interaction triggered during a live set is cryptographically signed and anchored to Base via a 0.01 USDC micro-fee. Fans pay a cent to verify the 'Live-ness' in real-time, and venues pay to verify setlist compliance. Payment acts as the signal that the audio wasn't just a pre-recorded playback. Why Hedera: Traditional performance verification is hindered by manual reporting and high friction. By turning every 'proof-of-play' into an x402 transaction, we create a continuous stream of low-latency, paid metadata. This transforms performance data into a liquid asset for royalty distribution and 'Verified Live' status for digital collectibles. Market: TAM $8.2B — The global live music industry's audit and compliance sector. | SAM $450M — The performance royalty and live metadata market for electronic and touring artists. | SOM $12M — Initial focused utility for mid-tier live electronic artists and modular synth performers requiring boutique verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "HEARTHASH" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-attestation protocol for touring musicians. Every track played or gear-interaction triggered during a live set is cryptographically signed and anchored to Base via a 0.01 USDC micro-fee. Fans pay a cent to verify the 'Live-ness' in real-time, and venues pay to verify setlist compliance. Payment acts as the signal that the audio wasn't just a pre-recorded playback. Discipline: Music & Sound Design (performance verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional performance verification is hindered by manual reporting and high friction. By turning every 'proof-of-play' into an x402 transaction, we create a continuous stream of low-latency, paid metadata. This transforms performance data into a liquid asset for royalty distribution and 'Verified Live' status for digital collectibles. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "HEARTHASH" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-gasless-loop-licensing-17-x402 Title: STEMFLOW · x402 Theme: Music & Sound Design (music) · loop rights Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-pop loop licensing for the DAW era. Every time a producer samples your sound, a 0.01 USDC x402 call unlocks the hi-def STEM and mints a micro-license to their Magic Link email sign-in. No subscription bloat—just pay per loop, settle on Hedera, and keep the creative momentum. Why Hedera: Traditional licensing is high-friction (contracts) or subscription-heavy (Splice). x402 turns every sound into a metered asset where the payment is the technical trigger for the file download, automating royalty distribution at the atomic level. Market: TAM $4.2B — The global music production software and royalty-free content market. | SAM $850M — The digital sample and loop market (Splice, Output, Arcade users). | SOM $12M — Web3-native music producers and AI-agent music generators requiring programmatic legal clearance for training/collaging. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEMFLOW" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-pop loop licensing for the DAW era. Every time a producer samples your sound, a 0.01 USDC x402 call unlocks the hi-def STEM and mints a micro-license to their Magic Link email sign-in. No subscription bloat—just pay per loop, settle on Hedera, and keep the creative momentum. Discipline: Music & Sound Design (loop rights). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is high-friction (contracts) or subscription-heavy (Splice). x402 turns every sound into a metered asset where the payment is the technical trigger for the file download, automating royalty distribution at the atomic level. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEMFLOW" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-social-audio-credits-18-x402 Title: STEMS · x402 Theme: Music & Sound Design (music) · microtransactions Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A permissionless attribution layer for sound designers. Every stem, loop, and MIDI sequence is gated by a 0.01 USDC x402 trigger. Collaborators sign with Social Login to instantly 'pull' high-fidelity assets into their DAW. Payment is the unlock: no credit, no download. Facilitators automate royalty splits to all contributors via Hedera transaction ides the moment an asset is accessed. Why Hedera: By moving from 'tipping' to 'metered access,' the value of a sound is enforced at the point of consumption. It turns a social gesture into a hard-coded financial primitive. Market: TAM $8.2B — The global digital music production and sample library market moving toward granular licensing. | SAM $420M — The independent 'bedroom producer' and sound kit marketplace economy. | SOM $12M — Web3-native music collaborators and early adopters of onchain DAW integrations on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEMS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A permissionless attribution layer for sound designers. Every stem, loop, and MIDI sequence is gated by a 0.01 USDC x402 trigger. Collaborators sign with Social Login to instantly 'pull' high-fidelity assets into their DAW. Payment is the unlock: no credit, no download. Facilitators automate royalty splits to all contributors via Hedera transaction ides the moment an asset is accessed. Discipline: Music & Sound Design (microtransactions). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'tipping' to 'metered access,' the value of a sound is enforced at the point of consumption. It turns a social gesture into a hard-coded financial primitive. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEMS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-gasless-synth-sharing-19-x402 Title: PatchStream · x402 Theme: Music & Sound Design (music) · preset sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A peer-to-peer marketplace for synthesizer patches where every 'Listen' and 'Download' is a micro-settlement. Users sign a 0.01 USDC HTS transfer authorization to instantly stream a high-fidelity preset preview or inject the parameters directly into their web-DAW, with the Hedera transaction id serving as the permanent proof-of-license and creator payout. Why Hedera: By replacing 'gasless' (which implies free) with x402 micropayments, we turn a social utility into a high-velocity economy. Moving the cursor over the 'Play' button triggers a signed intent, making the friction of paying 1 cent lower than the friction of an ad or a subscription. Market: TAM $2.8B — Total addressable spend in the hobbyist and professional digital audio workstation (DAW) ecosystem. | SAM $450M — The global virtual instrument and sample pack market moving toward granular, per-use licensing. | SOM $12M — Early-adopter sound designers, Serum/Vital power users, and AI-composition agents seeking programmatic audio assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PatchStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A peer-to-peer marketplace for synthesizer patches where every 'Listen' and 'Download' is a micro-settlement. Users sign a 0.01 USDC HTS transfer authorization to instantly stream a high-fidelity preset preview or inject the parameters directly into their web-DAW, with the Hedera transaction id serving as the permanent proof-of-license and creator payout. Discipline: Music & Sound Design (preset sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing 'gasless' (which implies free) with x402 micropayments, we turn a social utility into a high-velocity economy. Moving the cursor over the 'Play' button triggers a signed intent, making the friction of paying 1 cent lower than the friction of an ad or a subscription. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PatchStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-onchain-audio-stems-20-x402 Title: STEMTAP · x402 Theme: Music & Sound Design (music) · stem distribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-stem extraction and sampling. Producers upload masters; creators pay 0.01 USDC to unlock individual instrument tracks (drums, bass, synth, vocals) for instant use in their DAW. Every sample is a micro-transactional license, settling on Hedera with a signature. Why Hedera: Traditional stem packs are expensive and bloated. x402 enables 'surgical sampling,' where a producer only pays for the specific kick drum or vocal chop they need, reducing friction for remixers and increasing high-volume revenue for sound designers. Market: TAM $2.8B — the total addressable market for the creator economy's music production and licensing segment. | SAM $450M — the global market for royalty-free loops and digital audio workstations (DAWs). | SOM $12M — targeting early-adopter phonk, hyperpop, and lofi producers experimenting with Base-native creative tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEMTAP" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-stem extraction and sampling. Producers upload masters; creators pay 0.01 USDC to unlock individual instrument tracks (drums, bass, synth, vocals) for instant use in their DAW. Every sample is a micro-transactional license, settling on Hedera with a signature. Discipline: Music & Sound Design (stem distribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional stem packs are expensive and bloated. x402 enables 'surgical sampling,' where a producer only pays for the specific kick drum or vocal chop they need, reducing friction for remixers and increasing high-volume revenue for sound designers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEMTAP" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-gasless-sample-challenges-21-x402 Title: StemSwap · x402 Theme: Music & Sound Design (music) · creative contests Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A competitive ecosystem where music producers pay 0.01 USDC to 'Stem-Swap' to download a secret sound source or submit a flip. Payment is the judge: entries are ranked by total USDC signal from the community. Producers earn 90% of the pool for their win, while the sample provider earns a micropayment royalty for every download-to-flip. Eliminates bot spam and creates a real-time bounty market for sound designers. Why Hedera: By replacing 'gasless' with x402 micropayments, we turn a cost-heavy contest into a profitable protocol. The 0.01 USDC fee acts as a proof-of-work mechanism, ensuring high-quality submissions and direct creator monetization. Market: TAM $1.2B — The creator economy segment utilizing micro-transactions for digital assets and collaborative IP. | SAM $180M — The global music production & sound pack market moving towards granular, contract-based collaboration. | SOM $4M — Active creative onchain communities (Catalog, Zora, Sound.xyz) seeking low-friction, high-frequency competition loops. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemSwap" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A competitive ecosystem where music producers pay 0.01 USDC to 'Stem-Swap' to download a secret sound source or submit a flip. Payment is the judge: entries are ranked by total USDC signal from the community. Producers earn 90% of the pool for their win, while the sample provider earns a micropayment royalty for every download-to-flip. Eliminates bot spam and creates a real-time bounty market for sound designers. Discipline: Music & Sound Design (creative contests). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing 'gasless' with x402 micropayments, we turn a cost-heavy contest into a profitable protocol. The 0.01 USDC fee acts as a proof-of-work mechanism, ensuring high-quality submissions and direct creator monetization. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemSwap" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-decentralized-sound-workshops-22-x402 Title: Sonic Logic · x402 Theme: Music & Sound Design (music) · music education Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A library of mastery-level modular synth presets and theory stems. Pay 0.01 USDC to unlock the logic behind a single sound. Learn sound design by transaction: every 'Reveal' micropayment streams the session file and technical breakdown directly to your DAW-ready environment via signed HTS transfer auth. Why Hedera: Moves music education from high-friction course subscriptions to low-friction granular discovery. Students pay per specific technique or 'patch' they actually want to learn, creating a high-velocity feedback loop for creators. Market: TAM $31B — Global music education and digital audio workstation (DAW) asset market. | SAM $850M — Onchain creators and bedroom producers using Base for asset management. | SOM $12M — Sound designers selling individual patch architecture to students via micropayments. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sonic Logic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A library of mastery-level modular synth presets and theory stems. Pay 0.01 USDC to unlock the logic behind a single sound. Learn sound design by transaction: every 'Reveal' micropayment streams the session file and technical breakdown directly to your DAW-ready environment via signed HTS transfer auth. Discipline: Music & Sound Design (music education). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves music education from high-friction course subscriptions to low-friction granular discovery. Students pay per specific technique or 'patch' they actually want to learn, creating a high-velocity feedback loop for creators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sonic Logic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-collaborative-audio-nfts-23-x402 Title: StemSync · x402 Theme: Music & Sound Design (music) · co-authored NFTs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Co-author stem-based soundscapes where every layer added or remix triggered executes a 0.01 USDC settlement to the previous contributors. Use x402 to automate the 'split-per-play' economy—signing an HTS transfer auth to unlock the master track or contribute a high-fidelity stem. Payment isn't the hurdle; it's the proof of attribution for the machine-listening era. Why Hedera: By shifting from 'minting' to 'metered collaboration,' the app rewards active sound design. x402 allows for granular attribution where a 1-cent micro-payment replaces complex legal royalty agreements for small-scale creators. Market: TAM $2.8B — Global digital music production and licensing market moving toward micro-licensing. | SAM $140M — Independent musicians and bedroom producers using web3 attribution tools. | SOM $12M — On-chain music collaborators and stem-sharing communities on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemSync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Co-author stem-based soundscapes where every layer added or remix triggered executes a 0.01 USDC settlement to the previous contributors. Use x402 to automate the 'split-per-play' economy—signing an HTS transfer auth to unlock the master track or contribute a high-fidelity stem. Payment isn't the hurdle; it's the proof of attribution for the machine-listening era. Discipline: Music & Sound Design (co-authored NFTs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'minting' to 'metered collaboration,' the app rewards active sound design. x402 allows for granular attribution where a 1-cent micro-payment replaces complex legal royalty agreements for small-scale creators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemSync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-gasless-midi-trading-24-x402 Title: Velocity · x402 Theme: Music & Sound Design (music) · MIDI asset exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Unlock a world-class MIDI sequence. Every drag-and-drop into your DAW is a direct USDC transfer to the composer. No subscriptions, just high-fidelity melodic primitives gated by HTS transfer. Authors get paid per download; agents get paid per training sample. Finalize the composition and the txn on Hedera in one click. Why Hedera: Traditional MIDI packs are plagued by bulk-pricing and piracy. x402 turns every file into a metered asset, allowing producers to pay only for the specific 'hook' or 'drum-fill' they need, while providing creators with immediate, per-use liquidity. Market: TAM $2.8B — Global music production software and collaborative creator economy tools. | SAM $450M — The digital plugin and sound pack market for independent producers and session musicians. | SOM $12M — On-demand MIDI sequences for hyper-niche genres (Phonk, Hyperpop, Lo-fi) and AI music-generation training sets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Velocity" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Unlock a world-class MIDI sequence. Every drag-and-drop into your DAW is a direct USDC transfer to the composer. No subscriptions, just high-fidelity melodic primitives gated by HTS transfer. Authors get paid per download; agents get paid per training sample. Finalize the composition and the txn on Hedera in one click. Discipline: Music & Sound Design (MIDI asset exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional MIDI packs are plagued by bulk-pricing and piracy. x402 turns every file into a metered asset, allowing producers to pay only for the specific 'hook' or 'drum-fill' they need, while providing creators with immediate, per-use liquidity. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Velocity" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-sample-provenance-vault-0-x402 Title: ROOTS · x402 Theme: Music & Sound Design (music) · sample library curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular sample marketplace where every 'Crates' search and 'Stem' download is a 0.01 USDC micro-transaction. Producers bypass high-cost monthly subscriptions to pay per audition and per download. Each payment is a provable on-chain signature that acts as an instant-use license, creating a permanent, verifiable audit trail of sample provenance from source to DAW. Why Hedera: The current sample industry is plagued by 'All-you-can-eat' subscriptions that underpay niche creators. x402 enables a 'pay-per-discovery' model where curators are compensated for every individual interaction, making high-fidelity provenance affordable for low-budget bedroom producers while securing royalties for originators via the Base transaction hash. Market: TAM $2.8B — The global digital music production and royalty licensing market. | SAM $450M — The addressable market of independent music producers and sound designers utilizing digital sample marketplaces. | SOM $12M — Target revenue from the 'per-call' volume of stem extractions and sample auditions by Base-native creative agents and niche sound-design labels. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ROOTS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular sample marketplace where every 'Crates' search and 'Stem' download is a 0.01 USDC micro-transaction. Producers bypass high-cost monthly subscriptions to pay per audition and per download. Each payment is a provable on-chain signature that acts as an instant-use license, creating a permanent, verifiable audit trail of sample provenance from source to DAW. Discipline: Music & Sound Design (sample library curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: The current sample industry is plagued by 'All-you-can-eat' subscriptions that underpay niche creators. x402 enables a 'pay-per-discovery' model where curators are compensated for every individual interaction, making high-fidelity provenance affordable for low-budget bedroom producers while securing royalties for originators via the Base transaction hash. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ROOTS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-mix-snapshot-ledger-1-x402 Title: StemSeal · x402 Theme: Music & Sound Design (music) · mix version archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Commit high-fidelity mix fingerprints to the Base ledger for $0.01 per version. Each export generates a cryptographically signed snapshot, settling proof-of-work and authorship before the client even hears the bounce. No more 'Final_Final_v2' confusion—only immutable, metered mix history. Why Hedera: Producers struggle with version control and attribution. By turning mix archiving into a per-export micropayment, the ledger becomes a high-integrity audit trail that prevents 'creative theft' and provides a verifiable timeline of a track's evolution. Market: TAM $420M — The global digital music production and collaborative project management market. | SAM $12M — Independent mix engineers and bedroom producers on Hedera. | SOM $850K — Power users of DAWs (Ableton/Logic) using automated export-to-chain plugins. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemSeal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Commit high-fidelity mix fingerprints to the Base ledger for $0.01 per version. Each export generates a cryptographically signed snapshot, settling proof-of-work and authorship before the client even hears the bounce. No more 'Final_Final_v2' confusion—only immutable, metered mix history. Discipline: Music & Sound Design (mix version archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Producers struggle with version control and attribution. By turning mix archiving into a per-export micropayment, the ledger becomes a high-integrity audit trail that prevents 'creative theft' and provides a verifiable timeline of a track's evolution. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemSeal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-synth-patch-provenance-2-x402 Title: OSCILLATE · x402 Theme: Music & Sound Design (music) · synthesizer preset sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered marketplace for high-fidelity sound design. Instead of buying bulk packs, users pay 0.01 USDC per 'Signal Blast' to preview a preset at full resolution or unlock the .syx file via HTS transfer. Creators earn instant, streaming royalties every time their sound is triggered in a production environment, turning sound design into a live equity asset rather than a one-time static sale. Why Hedera: Current preset markets suffer from mass-piracy and 'filler' content. By moving to a pay-per-load model (x402), creators are incentivized to build 'hit' sounds that get used repeatedly by producers and AI-composition agents, with every audition settling a micro-transaction on Hedera. Market: TAM $28B — The global digital music production software and virtual instrument market. | SAM $1.2B — The professional music producer and home studio hobbyist market transitioning to cloud-based sound libraries. | SOM $18M — Independent sound designers and boutique synth developers utilizing micropayments for granular distribution on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "OSCILLATE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered marketplace for high-fidelity sound design. Instead of buying bulk packs, users pay 0.01 USDC per 'Signal Blast' to preview a preset at full resolution or unlock the .syx file via HTS transfer. Creators earn instant, streaming royalties every time their sound is triggered in a production environment, turning sound design into a live equity asset rather than a one-time static sale. Discipline: Music & Sound Design (synthesizer preset sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current preset markets suffer from mass-piracy and 'filler' content. By moving to a pay-per-load model (x402), creators are incentivized to build 'hit' sounds that get used repeatedly by producers and AI-composition agents, with every audition settling a micro-transaction on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "OSCILLATE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-sample-remix-rights-3-x402 Title: StemFlow · x402 Theme: Music & Sound Design (music) · remix licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless marketplace for high-fidelity stems where every 'Play' and 'Download' is a settled micro-license. Using x402, producers sign $0.01 HTS transfer authorizations to instantly unlock DAW-ready wav files and legal clearance. No subscriptions, no upfront bundles—just pay-per-stem flow that converts listeners into legal remixers via the transaction hash. Why Hedera: Turning remixing into a metered utility removes the friction of high-cost sample packs. By making the payment the 'access key' to the raw audio, the facilitator ensures that the creator is paid instantly for every unique pull, while providing the remixer an on-chain receipt of usage rights. Market: TAM $2.8B — The creator economy's total spend on digital assets, stock media, and intellectual property licensing. | SAM $450M — The global music production software and sample library market moving toward granular, per-asset liquidity. | SOM $12M — Independent electronic music producers on Hedera using decentralized distribution agents for instant licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless marketplace for high-fidelity stems where every 'Play' and 'Download' is a settled micro-license. Using x402, producers sign $0.01 HTS transfer authorizations to instantly unlock DAW-ready wav files and legal clearance. No subscriptions, no upfront bundles—just pay-per-stem flow that converts listeners into legal remixers via the transaction hash. Discipline: Music & Sound Design (remix licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Turning remixing into a metered utility removes the friction of high-cost sample packs. By making the payment the 'access key' to the raw audio, the facilitator ensures that the creator is paid instantly for every unique pull, while providing the remixer an on-chain receipt of usage rights. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-live-loop-provenance-4-x402 Title: STEMSTAMP · x402 Theme: Music & Sound Design (music) · live performance loops Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time loop capture engine where every 'Commit to Master' action facilitates a 0.01 USDC settlement. Performers pay to cryptographically stamp loops into a global stem library, while listeners pay per-loop to unlock high-fidelity stems for their own live setups. Live performance isn't just recorded; it's metered and monetized at the moment of inspiration. Why Hedera: By turning the act of 'saving' a loop into a micropayment event, you create a high-velocity friction point that validates the artist's intent and immediately funds the protocol's storage and provenance layer. Market: TAM $4.2B — The global music production software and digital content creation market. | SAM $850M — The growing market for 'Live-to-DAW' hardware and performance-based software subscriptions. | SOM $12M — Web3-native electronic musicians and live-looping streamers on platforms like Audius and Zora. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STEMSTAMP" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time loop capture engine where every 'Commit to Master' action facilitates a 0.01 USDC settlement. Performers pay to cryptographically stamp loops into a global stem library, while listeners pay per-loop to unlock high-fidelity stems for their own live setups. Live performance isn't just recorded; it's metered and monetized at the moment of inspiration. Discipline: Music & Sound Design (live performance loops). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the act of 'saving' a loop into a micropayment event, you create a high-velocity friction point that validates the artist's intent and immediately funds the protocol's storage and provenance layer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STEMSTAMP" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-virtual-instrument-tokens-5-x402 Title: Sonic Tap · x402 Theme: Music & Sound Design (music) · instrument sample packs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-trigger digital foley. A library of high-fidelity instrument samples where every 'note on' event or file download executes an x402 micropayment. Creators stream revenue per individual beat used in a DAW, rather than selling bulk packs that get pirated. Built for the era of AI-generated music and real-time collaborative production. Why Hedera: Traditional sample packs suffer from massive leakage and piracy. x402 turns the instrument itself into a metered utility. By moving from a $50 upfront fee to a $0.01 per-pull model, producers get infinite range for zero overhead, and creators capture value from every single session. Market: TAM $2.8B — The creator economy segment for digital assets and royalty-bearing media. | SAM $420M — The global music production software and sample library market. | SOM $18M — The niche for high-end boutique sample makers and AI music agents requiring programmatic access to licensed sounds. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sonic Tap" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-trigger digital foley. A library of high-fidelity instrument samples where every 'note on' event or file download executes an x402 micropayment. Creators stream revenue per individual beat used in a DAW, rather than selling bulk packs that get pirated. Built for the era of AI-generated music and real-time collaborative production. Discipline: Music & Sound Design (instrument sample packs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional sample packs suffer from massive leakage and piracy. x402 turns the instrument itself into a metered utility. By moving from a $50 upfront fee to a $0.01 per-pull model, producers get infinite range for zero overhead, and creators capture value from every single session. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sonic Tap" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-sound-effect-provenance-6-x402 Title: SND-BY-BIT · x402 Theme: Music & Sound Design (music) · sound effect libraries Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless API for sound designers to monetize Foley at the atomic level. Instead of $50 sound packs containing 200 files, users pay 0.01 USDC to instantly unlock and download a single high-fidelity WAV. Perfect for game engine integrations and AI training pipelines where builders only pay for the specific samples they trigger or use in a prompt. Why Hedera: Shifts from 'verification' to a 'pay-per-use' distribution model. HTS transfer allows for frictionless, sub-cent transactions that make single-sound licensing viable, turning the library into a metered utility rather than a static store. Market: TAM $2.1B — The total creator economy spend on digital assets and licensing. | SAM $480M — The global production music and sound design market transition to API-based consumption. | SOM $12M — Indie game developers, AI sound-gen agents, and TikTok editors paying for per-clip usage. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SND-BY-BIT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless API for sound designers to monetize Foley at the atomic level. Instead of $50 sound packs containing 200 files, users pay 0.01 USDC to instantly unlock and download a single high-fidelity WAV. Perfect for game engine integrations and AI training pipelines where builders only pay for the specific samples they trigger or use in a prompt. Discipline: Music & Sound Design (sound effect libraries). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from 'verification' to a 'pay-per-use' distribution model. HTS transfer allows for frictionless, sub-cent transactions that make single-sound licensing viable, turning the library into a metered utility rather than a static store. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SND-BY-BIT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-composer-cue-tokens-7-x402 Title: SONIC STREAMS · x402 Theme: Music & Sound Design (music) · media scoring segments Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless foley and score API for game devs and streamers. Instead of bulk licenses, pay 0.01 USDC to pull a procedurally generated, sync-ready music cue or sound effect directly into your timeline. Each call triggers a Base settlement that doubles as a cryptographically verifiable broadcast license. Stop paying for libraries; pay for the notes you use. Why Hedera: By shifting from 'NFT ownership' to 'pay-per-use utility,' the composer gets immediate streaming revenue from high-frequency users (devs/streamers) while the x402 protocol handles the micro-licensing logic without the friction of a marketplace. Market: TAM $2.8B — The global production music and sound design market. | SAM $450M — Revenue from indie game assets and stock audio subscriptions. | SOM $12M — Micro-licensing volume for AI-generated and procedurally assisted media scoring. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SONIC STREAMS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless foley and score API for game devs and streamers. Instead of bulk licenses, pay 0.01 USDC to pull a procedurally generated, sync-ready music cue or sound effect directly into your timeline. Each call triggers a Base settlement that doubles as a cryptographically verifiable broadcast license. Stop paying for libraries; pay for the notes you use. Discipline: Music & Sound Design (media scoring segments). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'NFT ownership' to 'pay-per-use utility,' the composer gets immediate streaming revenue from high-frequency users (devs/streamers) while the x402 protocol handles the micro-licensing logic without the friction of a marketplace. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SONIC STREAMS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-collaborative-track-tokens-8-x402 Title: MasterNode · x402 Theme: Music & Sound Design (music) · co-creation tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time 'revenue faucet' for music production. Instead of static NFTs, MasterNode meters every play or stem-download. Collaborators sign a multi-author HTS transfer manifest; users pay 0.01 USDC to unlock an isolated stem or high-fidelity playback. Every micro-transaction is instantly split and pushed to all contributors' Magic Link email sign-ins on-chain. Stop tracking rights — start streaming payments. Why Hedera: Legacy music rights are bogged down by quarterly payouts and complex royalty math. x402 turns a song into a live API. By making the payment the access primitive, you ensure every contributor is paid at the millisecond of consumption, perfect for AI sampling and social sharing. Market: TAM $28B — The global music streaming and intellectual property licensing industry. | SAM $450M — The independent music production and creator-licensing market adopting on-chain settlement. | SOM $12M — Early adopters in the 'Lo-Fi' and 'Sample Pack' communities using micropayments for stem access. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MasterNode" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time 'revenue faucet' for music production. Instead of static NFTs, MasterNode meters every play or stem-download. Collaborators sign a multi-author HTS transfer manifest; users pay 0.01 USDC to unlock an isolated stem or high-fidelity playback. Every micro-transaction is instantly split and pushed to all contributors' Magic Link email sign-ins on-chain. Stop tracking rights — start streaming payments. Discipline: Music & Sound Design (co-creation tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Legacy music rights are bogged down by quarterly payouts and complex royalty math. x402 turns a song into a live API. By making the payment the access primitive, you ensure every contributor is paid at the millisecond of consumption, perfect for AI sampling and social sharing. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MasterNode" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-mastering-provenance-tags-9-x402 Title: SONIC SIGNATURE · x402 Theme: Music & Sound Design (music) · audio mastering records Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Audio engineering as a verifiable service. Pay 0.01 USDC to generate a cryptographic 'Mastering Seal' for your track. The app executes a signal-chain audit and signs the metadata via HTS transfer. No subscription needed—just pay per render to lock the provenance of your sound on-chain. Producers pay to certify; labels pay to verify. Why Hedera: Moving from static NFTs to a pay-per-use certification model treats mastering provenance as a utility transaction. By gating the 'Seal' generation behind an x402 payment, the act of certification becomes a micro-metered professional service. Market: TAM $2.1B — The global music mastering and digital rights management market. | SAM $450M — Independent producers and DIY musicians using digital distribution. | SOM $12M — Early adopters in the 'On-chain Music' scene (Sound.xyz, Mint.fun users) requiring verifiable technical metadata. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SONIC SIGNATURE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Audio engineering as a verifiable service. Pay 0.01 USDC to generate a cryptographic 'Mastering Seal' for your track. The app executes a signal-chain audit and signs the metadata via HTS transfer. No subscription needed—just pay per render to lock the provenance of your sound on-chain. Producers pay to certify; labels pay to verify. Discipline: Music & Sound Design (audio mastering records). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from static NFTs to a pay-per-use certification model treats mastering provenance as a utility transaction. By gating the 'Seal' generation behind an x402 payment, the act of certification becomes a micro-metered professional service. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SONIC SIGNATURE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-remix-chain-registry-10-x402 Title: Lineage · x402 Theme: Music & Sound Design (music) · remix lineage tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Stem-level provenance for the loop economy. Pay 0.05 USDC to fork a track, instantly splitting royalties to all previous contributors in the lineage chain via x402 primitives. Each remix call executes a settlement that unlocks the high-quality masters and signs your contribution into the immutable family tree. Why Hedera: By turning 'remixing' into a paid state transition, we solve the attribution problem. The payment is the mechanism that triggers the cryptographic proof of lineage, ensuring creators are paid for their influence in real-time. Market: TAM $4.2B — Global music production and sync licensing market moving toward automated micro-royalties. | SAM $850M — Independent electronic music creators and sample pack boutique owners. | SOM $12M — DAWs and plugin suites integrating automated lineage payments for sample usage. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Lineage" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Stem-level provenance for the loop economy. Pay 0.05 USDC to fork a track, instantly splitting royalties to all previous contributors in the lineage chain via x402 primitives. Each remix call executes a settlement that unlocks the high-quality masters and signs your contribution into the immutable family tree. Discipline: Music & Sound Design (remix lineage tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'remixing' into a paid state transition, we solve the attribution problem. The payment is the mechanism that triggers the cryptographic proof of lineage, ensuring creators are paid for their influence in real-time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Lineage" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-songwriting-drafts-mint-11-x402 Title: InkTrace · x402 Theme: Music & Sound Design (music) · lyric version control Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Micropayment-gated version control for lyricists. Pay $0.01 per 'Commit' to cryptographically timestamp a draft. Fans or collaborators pay $0.05 to unlock the 'Process Log', viewing the evolution of a hit song line-by-line. Protects IP via EIP-712 signatures while turning the creative process into a paid ledger. Why Hedera: Traditional NFTs are too heavy for granular draft tracking. x402 allows for high-frequency, low-cost 'save points' that establish a verifiable chain of custody for lyrics. It turns the 'deleted scenes' of songwriting into a new revenue stream for artists. Market: TAM $1.4B — The global music publishing and IP protection market migrating to automated, on-chain registries. | SAM $22M — 1.5M independent songwriters and topliners using digital workstations and note-taking apps. | SOM $850K — Early adopters in the web3 music scene and ghostwriters requiring immutable proof of work for royalty disputes. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Micropayment-gated version control for lyricists. Pay $0.01 per 'Commit' to cryptographically timestamp a draft. Fans or collaborators pay $0.05 to unlock the 'Process Log', viewing the evolution of a hit song line-by-line. Protects IP via EIP-712 signatures while turning the creative process into a paid ledger. Discipline: Music & Sound Design (lyric version control). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFTs are too heavy for granular draft tracking. x402 allows for high-frequency, low-cost 'save points' that establish a verifiable chain of custody for lyrics. It turns the 'deleted scenes' of songwriting into a new revenue stream for artists. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-field-recording-mint-12-x402 Title: HUM · x402 Theme: Music & Sound Design (music) · ambient sound archives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time sonic marketplace where every 'Listen' and 'Download' of rare, high-fidelity ambient field recordings is a discrete 0.01 USDC settlement. Users pay-per-sample to unlock high-bitrate WAV files directly into their DAWs, while contributors earn instant micropayments for every second of sound telemetry consumed. No subscriptions, just a metered raw audio tap. Why Hedera: Shifting from a monolithic 'minting' model to a fluid consumption model turns the archive into a liquid utility. The x402 protocol ensures creators are compensated for the exact granular demand of their data, while sound designers only pay for the specific atmospheres they use in a project. Market: TAM $8.2B — The global digital audio content and stock music licensing market. | SAM $450M — The specialized sound design, foley, and boutique sample pack industry. | SOM $12M — Web3-native music producers and game developers seeking on-chain licensing provenance on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "HUM" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time sonic marketplace where every 'Listen' and 'Download' of rare, high-fidelity ambient field recordings is a discrete 0.01 USDC settlement. Users pay-per-sample to unlock high-bitrate WAV files directly into their DAWs, while contributors earn instant micropayments for every second of sound telemetry consumed. No subscriptions, just a metered raw audio tap. Discipline: Music & Sound Design (ambient sound archives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from a monolithic 'minting' model to a fluid consumption model turns the archive into a liquid utility. The x402 protocol ensures creators are compensated for the exact granular demand of their data, while sound designers only pay for the specific atmospheres they use in a project. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "HUM" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-sound-design-presets-13-x402 Title: Pulse · x402 Theme: Music & Sound Design (music) · fx preset marketplaces Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A low-latency sound design rack where every 'Load Preset' action is a 0.01 USDC micro-transaction. Creators upload complex FX chains (Serum, Vital, or VST-specific XMLs) and get paid instantly every time a producer auditions or drags a preset into their DAW. No subscriptions; just pay for the textures you use. Why Hedera: Traditional marketplaces force bulk purchases of packs where 90% of sounds go unused. x402 enables a 'Pay-per-Patch' model, turning presets into liquid assets that reward sound designers for individual high-utility creations rather than marketing volume. Market: TAM $2.8B — The global music production software and digital content market, increasingly shifting toward granular, cloud-based asset retrieval. | SAM $450M — The annual spend of bedroom producers and independent sound engineers on digital assets and boutique presets. | SOM $12M — Specialized focus on the Base/HashPack ecosystem for immediate DAW-to-blockchain preset injection. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Pulse" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A low-latency sound design rack where every 'Load Preset' action is a 0.01 USDC micro-transaction. Creators upload complex FX chains (Serum, Vital, or VST-specific XMLs) and get paid instantly every time a producer auditions or drags a preset into their DAW. No subscriptions; just pay for the textures you use. Discipline: Music & Sound Design (fx preset marketplaces). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional marketplaces force bulk purchases of packs where 90% of sounds go unused. x402 enables a 'Pay-per-Patch' model, turning presets into liquid assets that reward sound designers for individual high-utility creations rather than marketing volume. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Pulse" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-album-art-audio-tokens-14-x402 Title: SONIC_LAYER · x402 Theme: Music & Sound Design (music) · integrated art & sound Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Direct-to-ear artistic provenance. No subscriptions, no bundles—just 0.01 USDC to unlock a high-fidelity stem or visual layer. Listeners micro-pay to reveal the sonic architecture of a track, while creators earn instant settlement for every individual 'play' or 'view' event. Audio becomes a metered interactive canvas. Why Hedera: By shifting from a one-time NFT mint to x402-metered access, the relationship moves from 'ownership' to 'continuous consumption.' It solves the liquidity issue of high-priced music NFTs by making the interaction cost negligible but high-frequency, creating a live stream of revenue for the artist. Market: TAM $18B — The global music streaming and digital collectibles market moving toward granular, per-use billing. | SAM $420M — The independent musician and digital art segment seeking alternative monetization beyond streaming. | SOM $12M — Early adopters in the Base ecosystem and creative-tech audiophiles. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SONIC_LAYER" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Direct-to-ear artistic provenance. No subscriptions, no bundles—just 0.01 USDC to unlock a high-fidelity stem or visual layer. Listeners micro-pay to reveal the sonic architecture of a track, while creators earn instant settlement for every individual 'play' or 'view' event. Audio becomes a metered interactive canvas. Discipline: Music & Sound Design (integrated art & sound). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a one-time NFT mint to x402-metered access, the relationship moves from 'ownership' to 'continuous consumption.' It solves the liquidity issue of high-priced music NFTs by making the interaction cost negligible but high-frequency, creating a live stream of revenue for the artist. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SONIC_LAYER" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-music-education-tokens-15-x402 Title: MasterStream · x402 Theme: Music & Sound Design (music) · tutorial authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Stop wasting time on 'expert' YouTube creators who hide their DAW project files. PAY-PER-STEP to unlock the internal routing, plugin chains, and MIDI logic of world-class producers. Every tutorial action—from viewing a specific compressor setting to downloading a stems package—requires a 0.01 USDC signed authorization. It’s a metered masterclass where you only pay for the insights you actually click on, proven by the instructor’s own cryptographic signature on the session data. Why Hedera: By turning tutorial content into a metered stream of micro-transactions, we solve the 'lumpy' cost of education. Students don't pay $300 for a course; they pay cents to unlock the specific technical secrets they need, while instructors receive instant, frictionless liquid settlement for their expertise. Market: TAM $31B — The global online music education and creator-economy software market increasingly driven by piece-rate learning. | SAM $450M — The addressable market for DAW-specific technical training and premium sample pack distributions transitioning to micro-access models. | SOM $12M — Focus on the high-end electronic music production niche using Base for low-gas, high-frequency technical unlocks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MasterStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Stop wasting time on 'expert' YouTube creators who hide their DAW project files. PAY-PER-STEP to unlock the internal routing, plugin chains, and MIDI logic of world-class producers. Every tutorial action—from viewing a specific compressor setting to downloading a stems package—requires a 0.01 USDC signed authorization. It’s a metered masterclass where you only pay for the insights you actually click on, proven by the instructor’s own cryptographic signature on the session data. Discipline: Music & Sound Design (tutorial authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning tutorial content into a metered stream of micro-transactions, we solve the 'lumpy' cost of education. Students don't pay $300 for a course; they pay cents to unlock the specific technical secrets they need, while instructors receive instant, frictionless liquid settlement for their expertise. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MasterStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-sound-asset-licensing-16-x402 Title: SonicDebit · x402 Theme: Music & Sound Design (music) · asset marketplace Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular audio boutique where every 'Save As' or 'Export' triggers a $0.01 settlement. Instead of bulky licensing contracts, creators pay per-sample-layering or per-stems-access. Metadata is signed by the facilitator, providing an immutable proof-of-payment hash that serves as your global usage license. Why Hedera: Moving from NFT ownership (high friction, high cost) to micropayment-based licensing (low friction, high volume) enables 'pay-as-you-compose' workflows. It eliminates the 'all-or-nothing' subscription model, allowing bedroom producers and AI music agents to license specific snares or synth patches on-demand. Market: TAM $2.6B — The global digital music content creation and royalty-free asset market. | SAM $480M — The secondary market for DAW plugins, sample packs, and royalty-free stems. | SOM $12M — Micro-licensing for independent lo-fi producers and generative music AI agents requiring clean training data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SonicDebit" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular audio boutique where every 'Save As' or 'Export' triggers a $0.01 settlement. Instead of bulky licensing contracts, creators pay per-sample-layering or per-stems-access. Metadata is signed by the facilitator, providing an immutable proof-of-payment hash that serves as your global usage license. Discipline: Music & Sound Design (asset marketplace). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from NFT ownership (high friction, high cost) to micropayment-based licensing (low friction, high volume) enables 'pay-as-you-compose' workflows. It eliminates the 'all-or-nothing' subscription model, allowing bedroom producers and AI music agents to license specific snares or synth patches on-demand. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SonicDebit" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-genre-evolution-tokens-17-x402 Title: Phylogeny · x402 Theme: Music & Sound Design (music) · music style tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to append a permanent 'lineage link' to any track. Your micropayment anchors a track's aesthetic DNA—citing its influences and offspring—into a global, real-time genre phylogeny. Metadata that pays the originator. Why Hedera: By making genre attribution a paid transaction, we turn musicology into a verifiable proof-of-influence. Creators earn when their 'sound' is cited as a parent, and curators are rewarded for mapping the evolution of underground scenes. Market: TAM $2.8B — The global music metadata and royalty management market migrating to real-time settlement. | SAM $110M — Independent producers and curators on Hedera using automated attribution tools. | SOM $4.5M — Early adopters in the 'onchain sound' and electronic music production community. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Phylogeny" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to append a permanent 'lineage link' to any track. Your micropayment anchors a track's aesthetic DNA—citing its influences and offspring—into a global, real-time genre phylogeny. Metadata that pays the originator. Discipline: Music & Sound Design (music style tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making genre attribution a paid transaction, we turn musicology into a verifiable proof-of-influence. Creators earn when their 'sound' is cited as a parent, and curators are rewarded for mapping the evolution of underground scenes. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Phylogeny" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-interactive-audio-nfts-18-x402 Title: StemLoop · x402 Theme: Music & Sound Design (music) · dynamic sound compositions Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized modular DAW where every sound layer is a micro-transaction. Pay $0.01 to trigger a stem, modulate a filter, or bridge a MIDI sequence in a live global composition. Creators earn instantly as users or AI-agents remix their stems to generate unique, session-locked soundscapes. Payment is the play button. Why Hedera: Existing NFT models gate access entirely; x402 allows for granular, pay-per-interaction sound design. This turns 'interactive audio' from a static asset into a live, metered performance instrument where every change is a settled transaction on Hedera. Market: TAM $8.2B — The global music streaming and composition software market moving toward automated agent-to-agent licensing. | SAM $450M — The digital music production and sample pack market transitioning to micro-licensing models. | SOM $12M — Web3 experimental musicians and generative art collectors on Hedera/Farcaster using HTS transfer for seamless UX. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemLoop" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized modular DAW where every sound layer is a micro-transaction. Pay $0.01 to trigger a stem, modulate a filter, or bridge a MIDI sequence in a live global composition. Creators earn instantly as users or AI-agents remix their stems to generate unique, session-locked soundscapes. Payment is the play button. Discipline: Music & Sound Design (dynamic sound compositions). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Existing NFT models gate access entirely; x402 allows for granular, pay-per-interaction sound design. This turns 'interactive audio' from a static asset into a live, metered performance instrument where every change is a settled transaction on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemLoop" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-dj-set-provenance-19-x402 Title: SonicProof · x402 Theme: Music & Sound Design (music) · live set archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: DJs broadcast live set stems or full recordings to a peer-to-peer ledger where listeners and curators pay 0.01 USDC per minute of high-fidelity stream or archive access. Each micropayment triggers a real-time 'Proof of Presence' attestation for the listener while streaming royalty fractions directly to the artist's wallet via Base. No subscriptions—you only pay for the sets you actually vibe to. Why Hedera: By shifting from static NFT mints to per-minute x402 utility, the app creates a continuous monetization stream for performers. It solves the 'hidden archive' problem where 90% of live sets are lost or unmonetized due to licensing friction. Micropayments handle the licensing logic at the packet level. Market: TAM $26B — Global live music performance revenue and digital streaming rights management. | SAM $450M — The digital live-stream music market and high-fidelity archival space (Bandcamp/SoundCloud listeners). | SOM $12M — Web3-native DJs and electronic music fans on Hedera early-adopting micro-revenue models. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SonicProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT DJs broadcast live set stems or full recordings to a peer-to-peer ledger where listeners and curators pay 0.01 USDC per minute of high-fidelity stream or archive access. Each micropayment triggers a real-time 'Proof of Presence' attestation for the listener while streaming royalty fractions directly to the artist's wallet via Base. No subscriptions—you only pay for the sets you actually vibe to. Discipline: Music & Sound Design (live set archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from static NFT mints to per-minute x402 utility, the app creates a continuous monetization stream for performers. It solves the 'hidden archive' problem where 90% of live sets are lost or unmonetized due to licensing friction. Micropayments handle the licensing logic at the packet level. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SonicProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-voice-sample-tokens-20-x402 Title: VocalStamp · x402 Theme: Music & Sound Design (music) · vocal sample authentication Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Sign or verify a vocal stem's provenance with a 0.01 USDC micro-fee. Every time a producer pulls a high-fidelity vocal sample for a project, the original artist is paid instantly via x402. No subscriptions, no licensing lawyers—just friction-less authentication and payment per 'Pull'. Developers can integrate this registry into any DAW to meter the usage of high-quality training data or sample libraries. Why Hedera: Moving away from lumpy NFT minting fees to a high-velocity utility model where the 'Check' or 'Fetch' of a sample is the unit of value. This ensures micro-royalties flow to vocalists every time their work is accessed or verified by a collaborator or an AI trainer. Market: TAM $32B — The global music licensing and synchronization industry. | SAM $1.2B — The total market for digital music production assets and royalty-free sample libraries. | SOM $45M — On-chain producers and AI developers requiring verifiable, clean vocal stems for training and production. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VocalStamp" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Sign or verify a vocal stem's provenance with a 0.01 USDC micro-fee. Every time a producer pulls a high-fidelity vocal sample for a project, the original artist is paid instantly via x402. No subscriptions, no licensing lawyers—just friction-less authentication and payment per 'Pull'. Developers can integrate this registry into any DAW to meter the usage of high-quality training data or sample libraries. Discipline: Music & Sound Design (vocal sample authentication). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving away from lumpy NFT minting fees to a high-velocity utility model where the 'Check' or 'Fetch' of a sample is the unit of value. This ensures micro-royalties flow to vocalists every time their work is accessed or verified by a collaborator or an AI trainer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VocalStamp" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-podcast-sound-mint-21-x402 Title: VeriVoice · x402 Theme: Music & Sound Design (music) · podcast audio authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — cryptographically verify audio provenance. Podcast hosts sign an HTS transfer packet to anchor an authenticity proof to the Hedera for every new episode upload. Listeners pay 1 cent to fetch the 'Proof of Origin' signature, ensuring the voice they hear isn't an AI-generated deepfake. Micropayments meter the verification API, making trust a low-friction, high-value utility for listeners and distribution platforms. Why Hedera: In an era of AI voice cloning, authenticity is a commodity. Moving from 'ownership NFTs' to 'verification-per-call' turns provenance into an active security service. x402 allows platforms to automate authenticity checks for pennies without subscription overhead. Market: TAM $2.8B — Total global podcasting advertising and distribution market vulnerable to deepfake disruption. | SAM $420M — The addressable market for podcast hosting and security protocols protecting against synthetic media. | SOM $12M — Target capture of high-stakes news, true crime, and celebrity podcast verification fees. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VeriVoice" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — cryptographically verify audio provenance. Podcast hosts sign an HTS transfer packet to anchor an authenticity proof to the Hedera for every new episode upload. Listeners pay 1 cent to fetch the 'Proof of Origin' signature, ensuring the voice they hear isn't an AI-generated deepfake. Micropayments meter the verification API, making trust a low-friction, high-value utility for listeners and distribution platforms. Discipline: Music & Sound Design (podcast audio authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: In an era of AI voice cloning, authenticity is a commodity. Moving from 'ownership NFTs' to 'verification-per-call' turns provenance into an active security service. x402 allows platforms to automate authenticity checks for pennies without subscription overhead. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VeriVoice" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-film-score-provenance-22-x402 Title: CueCard · x402 Theme: Music & Sound Design (music) · cinematic composition Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-stem access to high-fidelity cinematic stems. Each 0.01 USDC call triggers an x402 'Proof of Sync' signature, authorizing a single-use license for editors. Facilitators settle usage in real-time, replacing complex backend licensing with atomic micropayments. Why Hedera: By moving from static NFT minting to a pay-per-use access model, composers monetize every audition and integration attempt, turning provenance into a granular, utility-based revenue stream. Market: TAM $2.4B — Global stock music and cinematic licensing market. | SAM $180M — Independent film editors and content creators on Hedera. | SOM $12M — Web3-native documentary and short-film sound designers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CueCard" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-stem access to high-fidelity cinematic stems. Each 0.01 USDC call triggers an x402 'Proof of Sync' signature, authorizing a single-use license for editors. Facilitators settle usage in real-time, replacing complex backend licensing with atomic micropayments. Discipline: Music & Sound Design (cinematic composition). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from static NFT minting to a pay-per-use access model, composers monetize every audition and integration attempt, turning provenance into a granular, utility-based revenue stream. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CueCard" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-ambient-track-tokens-23-x402 Title: AuraFlow · x402 Theme: Music & Sound Design (music) · ambient music archives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Stream an infinite, procedurally generated ambient soundscape. No subscriptions; you pay per minute of audio generation. Using x402, listeners fund sound designers directly in real-time. The protocol meters high-fidelity stems, allowing developers to pay-per-sample to integrate these textures into their own games or apps via secure HTS transfer signatures. Why Hedera: Shifts from 'ownership' (NFTs) to 'utility' (pay-per-minute/sample). This solves the friction of monthly music subscriptions for background noise and creates a new revenue stream for sound designers where every second of playback is a micro-settlement on Hedera. Market: TAM $8.5B — Global background music and stock audio licensing industry. | SAM $420M — The atmospheric sound market, including spa, focus, and meditation app revenues. | SOM $12M — Early adopters in the web3 productivity space and developers building 'vibes-as-a-service' for metaverse environments. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "AuraFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Stream an infinite, procedurally generated ambient soundscape. No subscriptions; you pay per minute of audio generation. Using x402, listeners fund sound designers directly in real-time. The protocol meters high-fidelity stems, allowing developers to pay-per-sample to integrate these textures into their own games or apps via secure HTS transfer signatures. Discipline: Music & Sound Design (ambient music archives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from 'ownership' (NFTs) to 'utility' (pay-per-minute/sample). This solves the friction of monthly music subscriptions for background noise and creates a new revenue stream for sound designers where every second of playback is a micro-settlement on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "AuraFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA music-instrument-sample-provenance-24-x402 Title: StemFlow · x402 Theme: Music & Sound Design (music) · instrumental sample rights Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A low-latency sound library where every 'drag-and-drop' is a micro-license. Instead of bulky NFT mints, producers pay 0.01 USDC to instantly unlock the high-res WAV and a cryptographic proof of provenance. This creates a frictionless 'metered-access' workflow for DAWs, where agents and humans pay only for the sounds they actually use in a session. Why Hedera: Shifts the model from speculative asset ownership (NFTs) to utility-based micropayment flow. x402 handles the 'Right to Use' at the file-system level, turning every sample into a pay-per-call API. Market: TAM $2.8B — The total addressable market for music production software and digital assets. | SAM $450M — The global royalty-free sample and loop market (Splice, Loopmasters). | SOM $12M — AI music generation agents and DAW-integrated micro-licensing workflows on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StemFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A low-latency sound library where every 'drag-and-drop' is a micro-license. Instead of bulky NFT mints, producers pay 0.01 USDC to instantly unlock the high-res WAV and a cryptographic proof of provenance. This creates a frictionless 'metered-access' workflow for DAWs, where agents and humans pay only for the sounds they actually use in a session. Discipline: Music & Sound Design (instrumental sample rights). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from speculative asset ownership (NFTs) to utility-based micropayment flow. x402 handles the 'Right to Use' at the file-system level, turning every sample into a pay-per-call API. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StemFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ============================================================================== THEME · Photography photographers, photo editors, photojournalists ============================================================================== ------------------------------------------------------------------------------ IDEA photography-immutable-photo-rights-0-x402 Title: Snapshot Proof · x402 Theme: Photography (photography) · copyright registry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency copyright layer for the screenshot era. 0.01 USDC per EIP-712 signature to timestamp image hashes on Hedera. Creators meter every license issuance, and platforms pay to verify provenance in real-time. No subscriptions, just provable ownership on-demand. Why Hedera: Current copyright registries are high-friction and expensive. By using x402, we turn registration into a micro-action. A crawler or AI scraper can be forced to pay 0.01 USDC to 'acknowledge' a rights-header, turning legal compliance into a micro-payment stream. Market: TAM $4.2B — The global content provenance and copyright protection market for AI-generated and human-captured media. | SAM $240M — The digital rights management (DRM) and stock photography metadata market. | SOM $12M — Independent photographers and NFT creators needing per-image timestamping and modular licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Snapshot Proof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency copyright layer for the screenshot era. 0.01 USDC per EIP-712 signature to timestamp image hashes on Hedera. Creators meter every license issuance, and platforms pay to verify provenance in real-time. No subscriptions, just provable ownership on-demand. Discipline: Photography (copyright registry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current copyright registries are high-friction and expensive. By using x402, we turn registration into a micro-action. A crawler or AI scraper can be forced to pay 0.01 USDC to 'acknowledge' a rights-header, turning legal compliance into a micro-payment stream. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Snapshot Proof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-onchain-photo-proof-1-x402 Title: TrueLens · x402 Theme: Photography (photography) · image authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-seal protocol for photographers. Every shutter click or metadata injection requires a 0.01 USDC micro-settlement to anchor the image's SHA-256 hash and geolocation to Base. Instead of a subscription, users pay only for 'Truth-as-a-Service,' generating a cryptographically verifiable 'Proof of Origin' receipt. Protect your IP and verify reality in an era of Deepfakes, one cent at a time. Why Hedera: By moving from a 'platform' to a 'metered utility,' we eliminate high monthly fees for casual photographers. x402 enables a direct-to-chain write operation where the payment is the trigger for the cryptographic seal, making the cost of authenticity transparent and negligible per unit. Market: TAM $4.2B — The global digital image provenance and anti-deepfake market as AI regulation mandates verifiable media. | SAM $280M — Projected spend by news agencies, legal firms, and professional freelancers on digital content protection and metadata verification. | SOM $12M — The immediate market of onchain photojournalists and mobile creators requiring instant, provable timestamps on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueLens" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-seal protocol for photographers. Every shutter click or metadata injection requires a 0.01 USDC micro-settlement to anchor the image's SHA-256 hash and geolocation to Base. Instead of a subscription, users pay only for 'Truth-as-a-Service,' generating a cryptographically verifiable 'Proof of Origin' receipt. Protect your IP and verify reality in an era of Deepfakes, one cent at a time. Discipline: Photography (image authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a 'platform' to a 'metered utility,' we eliminate high monthly fees for casual photographers. x402 enables a direct-to-chain write operation where the payment is the trigger for the cryptographic seal, making the cost of authenticity transparent and negligible per unit. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueLens" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-smart-photo-royalties-2-x402 Title: SHUTTER · x402 Theme: Photography (photography) · royalty automation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless licensing layer for digital imagery. Instead of subscription silos, developers and creators pay $0.01 USDC to programmatically unlock high-res source files or commercial rights. Every 'GET' request triggers an HTS transfer transfer directly to the photographer, enabling sub-cent licensing for AI training, social feeds, and micro-publishing. Why Hedera: By turning high-res access into a metered API call, we solve the friction of stock photography. One signature unlocks one image—ideal for AI agents or blogs that need legal clearance without a $30/mo subscription. Market: TAM $15.5B — Global stock photography and digital asset management market moving toward granular, verifiable licensing. | SAM $1.2B — The headless 'pay-per-pixel' market for AI training data and automated web publishing. | SOM $8.5M — Initial reach via Base-native dApps and dev-tooling integrations using HashPack for seamless auth. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SHUTTER" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless licensing layer for digital imagery. Instead of subscription silos, developers and creators pay $0.01 USDC to programmatically unlock high-res source files or commercial rights. Every 'GET' request triggers an HTS transfer transfer directly to the photographer, enabling sub-cent licensing for AI training, social feeds, and micro-publishing. Discipline: Photography (royalty automation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning high-res access into a metered API call, we solve the friction of stock photography. One signature unlocks one image—ideal for AI agents or blogs that need legal clearance without a $30/mo subscription. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SHUTTER" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-collaborative-edits-chain-3-x402 Title: Darkroom · x402 Theme: Photography (photography) · collaborative editing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-layer. Turn photo editing into a micro-transactional marketplace where every professional transform—color grade, mask, or retouch—is a paid 'pull' from a collaborator's private preset library. Each edit is an atomic transaction signed by your Magic Link email sign-in, instantly settling revenue to the contributor and committing the delta to the chain. No subscriptions, just pay for the specific human or AI expertise applied to your canvas. Why Hedera: By shifting from 'tracking history' to 'paying per operation,' the app turns a passive log into an active revenue engine for retouchers. x402 handles the high-frequency, low-latency settlement required for multi-layer collaborative workflows. Market: TAM $8.5B — Global photo editing software and creative collaboration market moving toward granular, usage-based pricing. | SAM $420M — Professional retouchers and digital artists adopting micro-licensing for their proprietary editing workflows. | SOM $12M — Web3-native creators and DAOs using Base to co-produce brand assets with instant royalty distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Darkroom" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-layer. Turn photo editing into a micro-transactional marketplace where every professional transform—color grade, mask, or retouch—is a paid 'pull' from a collaborator's private preset library. Each edit is an atomic transaction signed by your Magic Link email sign-in, instantly settling revenue to the contributor and committing the delta to the chain. No subscriptions, just pay for the specific human or AI expertise applied to your canvas. Discipline: Photography (collaborative editing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'tracking history' to 'paying per operation,' the app turns a passive log into an active revenue engine for retouchers. x402 handles the high-frequency, low-latency settlement required for multi-layer collaborative workflows. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Darkroom" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-decentralized-portfolios-4-x402 Title: SilverGate · x402 Theme: Photography (photography) · photographer profiles Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A premium photography gate where every high-resolution view or portfolio download costs 0.01 USDC. Photographers monetize their craft instantly, transforming the traditional gallery into a metered digital exhibition where fans pay per interaction and AI scrapers pay per frame. Why Hedera: Shifts the portfolio model from a static resume to a value-generating asset. By making every 'view' or 'save' a micro-transaction, it eliminates the need for ads or subscription tiers while protecting intellectual property through a pay-per-access primitive. Market: TAM $4.2B — The global photography and stock image market transitioning toward decentralized ownership and micro-licensing. | SAM $450M — The addressable market for independent photographers and high-end visual artists using blockchain for DRM and licensing. | SOM $12M — Initial capture targeting early-adopter crypto-photographers and collectors on Hedera testnet using micropayment-to-unlock models. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SilverGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A premium photography gate where every high-resolution view or portfolio download costs 0.01 USDC. Photographers monetize their craft instantly, transforming the traditional gallery into a metered digital exhibition where fans pay per interaction and AI scrapers pay per frame. Discipline: Photography (photographer profiles). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the portfolio model from a static resume to a value-generating asset. By making every 'view' or 'save' a micro-transaction, it eliminates the need for ads or subscription tiers while protecting intellectual property through a pay-per-access primitive. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SilverGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-onchain-usage-tracking-5-x402 Title: LENSCHECK · x402 Theme: Photography (photography) · photo usage monitoring Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-ping photo tracking. Embed an x402-pixel in your imagery; every time the asset is loaded, rendered, or scraped, the host must sign an HTS transfer micropayment to maintain 'Licensing Valid' status. No more bulk audits—revenue scales with visibility. Why Hedera: By turning the 'tracking' into a 'metered heartbeat,' the photographer is paid for the actual attention the image receives in real-time, rather than chasing violations after the fact. Market: TAM $4.2B — Global digital rights management and image licensing market. | SAM $120M — Professional stock photographers and digital asset managers transitioning to automated licensing. | SOM $8M — Independent photogrammetry artists and high-end editorial photographers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LENSCHECK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-ping photo tracking. Embed an x402-pixel in your imagery; every time the asset is loaded, rendered, or scraped, the host must sign an HTS transfer micropayment to maintain 'Licensing Valid' status. No more bulk audits—revenue scales with visibility. Discipline: Photography (photo usage monitoring). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the 'tracking' into a 'metered heartbeat,' the photographer is paid for the actual attention the image receives in real-time, rather than chasing violations after the fact. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LENSCHECK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-nft-prints-marketplace-6-x402 Title: ProofSheet · x402 Theme: Photography (photography) · photo prints trading Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity photo proofing and trading layer where creators earn 0.01 USDC per high-res 'Reveal' or 'Transfer' call. Eliminate listing fees and gas friction by making digital ownership a metered utility. Collectors pay-per-view to unlock raw file access, and pay-per-hop to route shipping data to print houses, with every state change settled via a signed HTS transfer micropayment. Why Hedera: Moving from a 'Marketplace' to a 'Settlement Layer' for pixels. By pricing the 'Reveal' at $0.01, collectors can browse low-res for free but pays-to-play with the high-fidelity source, creating a high-velocity micro-revenue stream for photographers that precedes the physical sale. Market: TAM $4.2B — The global online photo printing and digital art collectibles market transitioning to on-chain verification. | SAM $850M — Revenue potential from the digital-to-physical photography niche adopting programmable micropayments. | SOM $12M — Target capture of high-volume digital photo proofing and limited edition trading volume on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ProofSheet" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity photo proofing and trading layer where creators earn 0.01 USDC per high-res 'Reveal' or 'Transfer' call. Eliminate listing fees and gas friction by making digital ownership a metered utility. Collectors pay-per-view to unlock raw file access, and pay-per-hop to route shipping data to print houses, with every state change settled via a signed HTS transfer micropayment. Discipline: Photography (photo prints trading). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a 'Marketplace' to a 'Settlement Layer' for pixels. By pricing the 'Reveal' at $0.01, collectors can browse low-res for free but pays-to-play with the high-fidelity source, creating a high-velocity micro-revenue stream for photographers that precedes the physical sale. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ProofSheet" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-proof-of-capture-7-x402 Title: Verifact · x402 Theme: Photography (photography) · capture verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: The only way to verify a high-stakes image is real. Every 'Capture-Proof' triggers a 0.01 USDC event that binds location, time, and device sensor data to a cryptographically signed receipt. No more fake news or AI-generated 'evidence'—if it's not verified on the ledger, it's just a file. Payment acts as the signal of authenticity. Why Hedera: In an era of deepfakes, verification is a premium service. Using x402 allows for high-frequency, low-cost proof generation without the friction of gas-abstraction or manual transfers. The 0.01 USDC fee functions as a 'Truth Tax' that prevents spam and funds the validation infrastructure. Market: TAM $2.1B — The global digital forensics and image authentication market for insurance, legal, and media industries. | SAM $430M — Professional investigative journalists, insurance adjusters, and supply chain auditors requiring instant metadata verification. | SOM $12M — Independent street photographers and citizen journalists on Hedera verifying 1M captures monthly. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Verifact" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT The only way to verify a high-stakes image is real. Every 'Capture-Proof' triggers a 0.01 USDC event that binds location, time, and device sensor data to a cryptographically signed receipt. No more fake news or AI-generated 'evidence'—if it's not verified on the ledger, it's just a file. Payment acts as the signal of authenticity. Discipline: Photography (capture verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: In an era of deepfakes, verification is a premium service. Using x402 allows for high-frequency, low-cost proof generation without the friction of gas-abstraction or manual transfers. The 0.01 USDC fee functions as a 'Truth Tax' that prevents spam and funds the validation infrastructure. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Verifact" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-smart-rights-marketplace-8-x402 Title: SNAPSHOT · x402 Theme: Photography (photography) · licensing exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity photo licensing protocol where human photographers and AI training agents pay 0.01 USDC per 'view-to-capture' or 'prompt-to-license'. Each request triggers an HTS transfer transfer, instantly settling rights via a Hedera transaction id. No subscriptions; pay only for the pixels you pipe into your project or model. Why Hedera: Moving from 'marketplaces' to 'metered access' solves the friction of bulk licensing. By turning the license into a per-call micropayment, creators get paid for every single impression/pull, and consumers avoid the overhead of large legal contracts. Market: TAM $15B — Global digital rights management and AI training data procurement. | SAM $1.2B — The professional stock photography and real-time news imagery market. | SOM $45M — On-chain image delivery for decentralized social (Lens/Farcaster) and AI model ingestion. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SNAPSHOT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity photo licensing protocol where human photographers and AI training agents pay 0.01 USDC per 'view-to-capture' or 'prompt-to-license'. Each request triggers an HTS transfer transfer, instantly settling rights via a Hedera transaction id. No subscriptions; pay only for the pixels you pipe into your project or model. Discipline: Photography (licensing exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'marketplaces' to 'metered access' solves the friction of bulk licensing. By turning the license into a per-call micropayment, creators get paid for every single impression/pull, and consumers avoid the overhead of large legal contracts. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SNAPSHOT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-chain-verified-edits-9-x402 Title: TrueShot · x402 Theme: Photography (photography) · edit validation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A low-latency signing engine for photojournalists. Instead of bulk licensing, agencies and viewers pay $0.01 to verify the cryptographic provenance and 'non-destructive' edit history of a single image. Each validation returns a Hedera transaction id, pinning the edit sequence to a global integrity ledger accessible via API. Why Hedera: By shifting from a platform subscription to a per-validation micropayment, we turn integrity into a commodity. News aggregators and social platforms can programmatically pay for 'truth status' only when a photo trends, reducing overhead for creators while ensuring every check is compensated. Market: TAM $4.2B — The global digital image metadata and licensing market, increasingly automated by AI agent filters. | SAM $850M — The market for decentralized identity and content provenance systems serving freelance photojournalists. | SOM $12M — Transactional volume from independent editorial bureaus and AI-detection scrapers triggering verification hooks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueShot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A low-latency signing engine for photojournalists. Instead of bulk licensing, agencies and viewers pay $0.01 to verify the cryptographic provenance and 'non-destructive' edit history of a single image. Each validation returns a Hedera transaction id, pinning the edit sequence to a global integrity ledger accessible via API. Discipline: Photography (edit validation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a platform subscription to a per-validation micropayment, we turn integrity into a commodity. News aggregators and social platforms can programmatically pay for 'truth status' only when a photo trends, reducing overhead for creators while ensuring every check is compensated. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueShot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-decentralized-feedback-10-x402 Title: Jury · x402 Theme: Photography (photography) · community critique Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-competitive photo critique engine where feedback is a paid asset. Photographers pay 0.01 USDC to push their work to the 'Jury'—a curated feed of high-rep peers. Critiquers earn direct micro-bounties for every actionable review, verified by on-chain sentiment logic. No vanity likes, no bots—only skin in the game for real aesthetic growth. Why Hedera: x402 transforms 'likes' into micro-transactions. By charging for the upload and rewarding the critique, it solves the 'low-effort feedback' problem common in free communities. Payment ensures the attention is real. Market: TAM $2.8B — The global photography education and digital peer-review market. | SAM $120M — Professional photographers and enthusiasts seeking structured, non-algorithmic mentorship. | SOM $5.5M — Early adopters in the Web3 photography space (NFT artists, Mirror creators) seeking peer review. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Jury" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-competitive photo critique engine where feedback is a paid asset. Photographers pay 0.01 USDC to push their work to the 'Jury'—a curated feed of high-rep peers. Critiquers earn direct micro-bounties for every actionable review, verified by on-chain sentiment logic. No vanity likes, no bots—only skin in the game for real aesthetic growth. Discipline: Photography (community critique). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: x402 transforms 'likes' into micro-transactions. By charging for the upload and rewarding the critique, it solves the 'low-effort feedback' problem common in free communities. Payment ensures the attention is real. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Jury" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-chain-provenance-logs-11-x402 Title: LensSeal · x402 Theme: Photography (photography) · provenance tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A sub-cent ledger for digital authenticity. Pay 0.01 USDC to seal every shutter press, edit, and transfer into an immutable provenance chain. Creators monetize the 'truth' by charging viewers to verify the original metadata and historical edits of high-value media. Why Hedera: By turning provenance into a pay-per-event action (HTS transfer), we replace expensive high-fee minting with atomic 0.01 USDC event logging. This makes constant metadata updates financially viable for working photographers. Market: TAM $8.4B — The global digital asset management and anti-deepfake verification market. | SAM $1.2B — Professional photojournalists, stock agencies, and forensic investigators requiring verifiable audit trails. | SOM $15M — Hedera testnet early adopters and on-chain investigators beta-testing decentralized truth protocols. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LensSeal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A sub-cent ledger for digital authenticity. Pay 0.01 USDC to seal every shutter press, edit, and transfer into an immutable provenance chain. Creators monetize the 'truth' by charging viewers to verify the original metadata and historical edits of high-value media. Discipline: Photography (provenance tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning provenance into a pay-per-event action (HTS transfer), we replace expensive high-fee minting with atomic 0.01 USDC event logging. This makes constant metadata updates financially viable for working photographers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LensSeal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-onchain-photo-challenges-12-x402 Title: Snapshot · x402 Theme: Photography (photography) · contest management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes visual battleground where every interaction is a settlement. 0.01 USDC to submit an entry, 0.01 USDC to cast a vote. Smart contracts enforce the prize pool distribution based on aggregate x402 signatures, ensuring global participation without high-gas friction. Entry fees go to the pool; voting fees go to the platform/curator. Why Hedera: By turning 'voting' and 'submitting' into metered micro-transactions, the contest filters for signal and skin-in-the-game while creating a continuous revenue stream for the facilitator. Market: TAM $4.5B - The global digital photography contest and creator rewards market. | SAM $210M - Specialized onchain contest platforms and photography DAO participants. | SOM $1.2M - Initial photography niche using Base for gasless-feeling USDC microrounds. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Snapshot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes visual battleground where every interaction is a settlement. 0.01 USDC to submit an entry, 0.01 USDC to cast a vote. Smart contracts enforce the prize pool distribution based on aggregate x402 signatures, ensuring global participation without high-gas friction. Entry fees go to the pool; voting fees go to the platform/curator. Discipline: Photography (contest management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'voting' and 'submitting' into metered micro-transactions, the contest filters for signal and skin-in-the-game while creating a continuous revenue stream for the facilitator. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Snapshot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-verified-client-contracts-13-x402 Title: SHUTTERPROOF · x402 Theme: Photography (photography) · client agreements Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for ephemeral, pay-per-clause photography service agreements. Clients pay 0.01 USDC to unlock specific usage rights, release watermarks, or sign-off on individual session deliverables. Instead of a bulky master contract, every milestone and usage permission is a micro-transactional event settled instantly on Hedera. Why Hedera: Traditional contracts are static and litigious. Making every 'approval' or 'image release' a paid transaction (x402) automates the trust mechanism, ensuring the photographer is paid for every deliverable instantly, while the client gets cryptographically signed proof of rights. Market: TAM $3.8B — The global gig economy contract management and digital rights enforcement market. | SAM $450M — The freelance photography and digital content licensing market transitioning to on-chain settlement. | SOM $12M — Professional photographers on Hedera using granular, micro-gated delivery systems. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SHUTTERPROOF" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for ephemeral, pay-per-clause photography service agreements. Clients pay 0.01 USDC to unlock specific usage rights, release watermarks, or sign-off on individual session deliverables. Instead of a bulky master contract, every milestone and usage permission is a micro-transactional event settled instantly on Hedera. Discipline: Photography (client agreements). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional contracts are static and litigious. Making every 'approval' or 'image release' a paid transaction (x402) automates the trust mechanism, ensuring the photographer is paid for every deliverable instantly, while the client gets cryptographically signed proof of rights. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SHUTTERPROOF" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-tip-jar-integration-14-x402 Title: Shutter · x402 Theme: Photography (photography) · microdonations Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: High-fidelity image licensing via pay-per-view micropayments. Instead of a voluntary tip jar, the 'Lens' protocol gates full-resolution RAW files and metadata behind a 0.01 USDC x402 trigger. Fans sign an HTS transfer permit to instantly reveal the high-res capture, with the facilitator settling the sub-cent royalty to the photographer's wallet in real-time. Turn every 'like' into a settled micro-transaction. Why Hedera: Shifting from 'optional tipping' to 'mandatory micro-consumption' leverages the low friction of the embedded wallet-signed x402 calls. It transforms photography from a stagnant gallery into a metered API of visual assets. Market: TAM $4.2B — The global professional photography and digital licensing market. | SAM $450M — The digital stock photo and creator royalty market shifting to web3 rails. | SOM $12M — Base-native mobile photographers and on-chain social media users (Farcaster/Lens). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Shutter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT High-fidelity image licensing via pay-per-view micropayments. Instead of a voluntary tip jar, the 'Lens' protocol gates full-resolution RAW files and metadata behind a 0.01 USDC x402 trigger. Fans sign an HTS transfer permit to instantly reveal the high-res capture, with the facilitator settling the sub-cent royalty to the photographer's wallet in real-time. Turn every 'like' into a settled micro-transaction. Discipline: Photography (microdonations). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from 'optional tipping' to 'mandatory micro-consumption' leverages the low friction of the embedded wallet-signed x402 calls. It transforms photography from a stagnant gallery into a metered API of visual assets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Shutter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-chain-linked-exif-15-x402 Title: TrueShot · x402 Theme: Photography (photography) · metadata anchoring Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Immutable EXIF provenance for professional photojournalists and forensic workflows. Secure your image integrity by anchoring GPS, timestamp, and device signatures to Base. Every shutter click generates a verifiable on-chain record, preventing deepfake manipulation and metadata stripping. Pay-per-anchor ensures every frame is a legal-grade asset. Why Hedera: Moving metadata anchoring from a free utility to a pay-per-use primitive creates a 'Proof of Capture' economy. In a world of AI-generated misinformation, a 0.01 USDC micro-transaction is a negligible cost for high-stakes verification but scales massively for automated press agencies and security systems. Market: TAM $3.8B — The global digital forensics and image verification market. | SAM $420M — Professional photographers and digital asset managers requiring immutable provenance. | SOM $12M — Web3 native photojournalists and insurance adjusters using mobile-first on-chain anchoring. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueShot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Immutable EXIF provenance for professional photojournalists and forensic workflows. Secure your image integrity by anchoring GPS, timestamp, and device signatures to Base. Every shutter click generates a verifiable on-chain record, preventing deepfake manipulation and metadata stripping. Pay-per-anchor ensures every frame is a legal-grade asset. Discipline: Photography (metadata anchoring). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving metadata anchoring from a free utility to a pay-per-use primitive creates a 'Proof of Capture' economy. In a world of AI-generated misinformation, a 0.01 USDC micro-transaction is a negligible cost for high-stakes verification but scales massively for automated press agencies and security systems. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueShot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-onchain-photo-licensing-16-x402 Title: ShutterProof · x402 Theme: Photography (photography) · license contracts Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Protect and monetize your lens work with instant, granular permissions. Every high-res download, commercial sub-license, or AI training usage is a 0.01 USDC event. No monthly subs—only pay for the specific usage rights you pull from the chain. Photographers earn per-interaction while users get a verifiable HTS transfer receipt as their legal proof-of-license. Why Hedera: Moving from static 'contracts' to x402-native metering allows for fractional licensing (e.g., pay-per-view vs. pay-per-print) and enables AI agents to legally ingest images for pennies via programmatic calls. Market: TAM $15B — Global digital image licensing market transitioning to real-time, programmable settlement. | SAM $450M — Onchain photo-journalists, stock photography buyers, and decentralized media outlets using Base. | SOM $12M — Professional photographers on Farcaster/Lens requiring automated, low-friction micro-licensing for digital assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ShutterProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Protect and monetize your lens work with instant, granular permissions. Every high-res download, commercial sub-license, or AI training usage is a 0.01 USDC event. No monthly subs—only pay for the specific usage rights you pull from the chain. Photographers earn per-interaction while users get a verifiable HTS transfer receipt as their legal proof-of-license. Discipline: Photography (license contracts). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from static 'contracts' to x402-native metering allows for fractional licensing (e.g., pay-per-view vs. pay-per-print) and enables AI agents to legally ingest images for pennies via programmatic calls. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ShutterProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-decentralized-photo-auctions-17-x402 Title: SHUTTERBAZE · x402 Theme: Photography (photography) · auction platform Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — bid-to-view. Every bid placed on an auction requires a micro-payment to the artist/curator. This turns high-intent auction browsing into immediate revenue. Bidders pay per bid-entry to prevent ghost-bidding and bot spam, while private 'Dark Room' previews are gated by 1-click x402 signatures. All auction settlements and bid-increments are finalized via Hedera transaction ides, ensuring the photographer is paid for the attention, not just the final sale. Why Hedera: Traditional auctions suffer from non-paying bidders and zero revenue for the artist if the reserve isn't met. By metering the 'right to bid' and the 'right to view,' the photographer earns USDC for every interaction. x402 eliminates the friction of gas fees and manual approvals, making bidding feel like a low-stakes social game with high-stakes assets. Market: TAM $4.2B - The global art auction market transitioning to digital-native, high-frequency micro-transaction settlement layers. | SAM $185M - Representing the digital collectibles and high-end NFT photography market focused on verified scarcity. | SOM $12M - Initial target of hyper-active photography collectors and DAO-based curators on Hedera using embedded wallet solutions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SHUTTERBAZE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — bid-to-view. Every bid placed on an auction requires a micro-payment to the artist/curator. This turns high-intent auction browsing into immediate revenue. Bidders pay per bid-entry to prevent ghost-bidding and bot spam, while private 'Dark Room' previews are gated by 1-click x402 signatures. All auction settlements and bid-increments are finalized via Hedera transaction ides, ensuring the photographer is paid for the attention, not just the final sale. Discipline: Photography (auction platform). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional auctions suffer from non-paying bidders and zero revenue for the artist if the reserve isn't met. By metering the 'right to bid' and the 'right to view,' the photographer earns USDC for every interaction. x402 eliminates the friction of gas fees and manual approvals, making bidding feel like a low-stakes social game with high-stakes assets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SHUTTERBAZE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-smart-watermark-registry-18-x402 Title: ProofShot · x402 Theme: Photography (photography) · watermark management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Protect and monetize creative assets through a high-frequency watermark registry. 0.01 USDC triggers an HTS transfer signature to cryptographically anchor a custom watermark to the Base ledger. Professional photographers and agencies pay per asset registered or per unique 'Permission-to-Use' token minted, generating an immutable, verifiable trail for every image in their portfolio. Why Hedera: Watermarking is historically a friction-heavy manual task; by making registration a 0.01 USDC micro-transaction, we turn a legal necessity into a seamless, high-velocity onchain habit. Use-case scale matches the volume of modern digital content production. Market: TAM $3.8B — Global digital rights management and image security market. | SAM $450M — Professional digital photographers and licensing agencies transitioning to onchain workflows. | SOM $12M — High-volume stock photo contributors and sports photographers requiring instant, low-cost proof-of-work registration. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ProofShot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Protect and monetize creative assets through a high-frequency watermark registry. 0.01 USDC triggers an HTS transfer signature to cryptographically anchor a custom watermark to the Base ledger. Professional photographers and agencies pay per asset registered or per unique 'Permission-to-Use' token minted, generating an immutable, verifiable trail for every image in their portfolio. Discipline: Photography (watermark management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Watermarking is historically a friction-heavy manual task; by making registration a 0.01 USDC micro-transaction, we turn a legal necessity into a seamless, high-velocity onchain habit. Use-case scale matches the volume of modern digital content production. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ProofShot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-chain-backed-photo-grants-19-x402 Title: Aperture Grant · x402 Theme: Photography (photography) · funding management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Every 'Like' is a micro-grant. Photographers publish high-res galleries where every view or download triggers a 0.01 USDC transfer directly from the viewer's wallet to the creator. No complex applications; just pay-per-view funding that builds an automated, transparent grant pool for your next project. Why Hedera: By shifting from a top-down 'grant' model to a bottom-up 'per-interaction' funding model, photographers receive continuous, streaming capital. Using x402 allows for granular funding (metered viewing) where the act of consumption is the act of patronage. Market: TAM $4.2B — The global grant-making and photography equipment financing market. | SAM $450M — The digital photography licensing and micro-patronage sector. | SOM $12M — Base-native documentary photographers and photojournalists seeking decentralized grant alternatives. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Aperture Grant" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Every 'Like' is a micro-grant. Photographers publish high-res galleries where every view or download triggers a 0.01 USDC transfer directly from the viewer's wallet to the creator. No complex applications; just pay-per-view funding that builds an automated, transparent grant pool for your next project. Discipline: Photography (funding management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a top-down 'grant' model to a bottom-up 'per-interaction' funding model, photographers receive continuous, streaming capital. Using x402 allows for granular funding (metered viewing) where the act of consumption is the act of patronage. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Aperture Grant" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-onchain-model-releases-20-x402 Title: Release · x402 Theme: Photography (photography) · legal documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Legal liability is a friction point in high-speed content production. 'Release' turns model consent into a metered onchain event. Each time a photographer generates or registers a digital release via the the embedded wallet-signed mobile interface, 0.01 USDC is streamed to the facilitator to anchor the legal hash and EIP-712 signature to Base. This eliminates the need for expensive legal sub-platforms, providing per-use immutable proof of consent for agencies and freelance creators. Why Hedera: Current legal-tech is subscription-bloated. By making the release form a 'pay-per-signature' primitive (x402), we align the cost of legal protection with the volume of a photographer's output. $0.01 per signature is a negligible cost for a million-dollar protection. Market: TAM $2.1B — The global digital asset management and legal compliance software market. | SAM $85M — The segment of the photography market requiring standardized commercial licensing and digital rights management. | SOM $4.2M — Targeting high-volume commercial photographers and UGC (User Generated Content) agencies on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Release" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Legal liability is a friction point in high-speed content production. 'Release' turns model consent into a metered onchain event. Each time a photographer generates or registers a digital release via the the embedded wallet-signed mobile interface, 0.01 USDC is streamed to the facilitator to anchor the legal hash and EIP-712 signature to Base. This eliminates the need for expensive legal sub-platforms, providing per-use immutable proof of consent for agencies and freelance creators. Discipline: Photography (legal documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current legal-tech is subscription-bloated. By making the release form a 'pay-per-signature' primitive (x402), we align the cost of legal protection with the volume of a photographer's output. $0.01 per signature is a negligible cost for a million-dollar protection. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Release" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-photo-data-monetization-21-x402 Title: RAWFEED · x402 Theme: Photography (photography) · data marketplace Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized raw-image telemetry stream where every metadata extraction (EXIF, geolocation, lighting conditions) costs exactly 0.01 USDC. Developers and AI researchers pay per data-point to train vision models, while photographers receive instant, granular settlement for every byte queried. No bundles, no subscriptions, just high-fidelity training data on-tap. Why Hedera: By turning static metadata into a metered API, we solve the 'all-or-nothing' licensing problem. x402 allows researchers to grab specific data points (e.g., only GPS tags from 10k photos) for pennies, creating a liquid market for data that is usually locked in silos or scraped for free. Market: TAM $4.5B — Global image processing and metadata analytics market by 2028. | SAM $250M — The emerging market for ethical, verifiable AI training sets and computer vision metadata. | SOM $12M — Base-native developers and decentralized AI (DePIN) projects requiring high-quality visual telemetry. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RAWFEED" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized raw-image telemetry stream where every metadata extraction (EXIF, geolocation, lighting conditions) costs exactly 0.01 USDC. Developers and AI researchers pay per data-point to train vision models, while photographers receive instant, granular settlement for every byte queried. No bundles, no subscriptions, just high-fidelity training data on-tap. Discipline: Photography (data marketplace). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning static metadata into a metered API, we solve the 'all-or-nothing' licensing problem. x402 allows researchers to grab specific data points (e.g., only GPS tags from 10k photos) for pennies, creating a liquid market for data that is usually locked in silos or scraped for free. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RAWFEED" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-chain-based-mentorship-22-x402 Title: Redline · x402 Theme: Photography (photography) · educational matching Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Stop doom-scrolling and start critiquing. Mentors post their portfolios as interactive galleries; mentees pay 0.05 USDC per 'Redline Request.' One payment triggers an HTS transfer transfer, unlocking a 60-second voice-note critique or a digital markup of the raw file. Every piece of advice is a settled transaction on Hedera, building a verifiable reputation score for the mentor and a proof-of-improvement log for the student. Pay per tip, not per hour. Why Hedera: Traditional mentorship is bogged down by scheduling and high retainer fees. By atomizing feedback into single-use paid micro-critiques, we lower the barrier for mentees and provide instant liquidity for experts using x402 primitives. Market: TAM $8.2B — The global online private tutoring and vocational creative training market. | SAM $450M — The digital photography education market, shifting toward micro-learning and 'snackable' professional feedback. | SOM $12M — Early-adopter hobbyist photographers on Hedera seeking professional portfolio reviews via mobile-first interfaces. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Redline" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Stop doom-scrolling and start critiquing. Mentors post their portfolios as interactive galleries; mentees pay 0.05 USDC per 'Redline Request.' One payment triggers an HTS transfer transfer, unlocking a 60-second voice-note critique or a digital markup of the raw file. Every piece of advice is a settled transaction on Hedera, building a verifiable reputation score for the mentor and a proof-of-improvement log for the student. Pay per tip, not per hour. Discipline: Photography (educational matching). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional mentorship is bogged down by scheduling and high retainer fees. By atomizing feedback into single-use paid micro-critiques, we lower the barrier for mentees and provide instant liquidity for experts using x402 primitives. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Redline" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-onchain-photo-presets-23-x402 Title: RAWFLOW · x402 Theme: Photography (photography) · preset licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Apply professional color grades instantly via the browser. Instead of buying $50 preset packs you never use, pay-per-render. Photographers upload LUTs/XMP data to the engine; users sign an HTS transfer permit to process a single high-res RAW or JPEG. Every 'Export' event triggers a direct micropayment to the creator's wallet, turning static assets into a high-frequency revenue stream for editors. Why Hedera: Shifts the model from static digital goods (which are easily pirated) to a metered compute service. The x402 primitive ensures the creator is paid for the literal application of their IP, rather than the file transfer. Market: TAM $4.2B — The global photo editing software and digital asset licensing market. | SAM $450M — The creator economy market for digital assets, filters, and mobile photo editing subscriptions. | SOM $12M — Onchain photographers and professional retouchers using decentralized storage for post-production workflows. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RAWFLOW" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Apply professional color grades instantly via the browser. Instead of buying $50 preset packs you never use, pay-per-render. Photographers upload LUTs/XMP data to the engine; users sign an HTS transfer permit to process a single high-res RAW or JPEG. Every 'Export' event triggers a direct micropayment to the creator's wallet, turning static assets into a high-frequency revenue stream for editors. Discipline: Photography (preset licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from static digital goods (which are easily pirated) to a metered compute service. The x402 primitive ensures the creator is paid for the literal application of their IP, rather than the file transfer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RAWFLOW" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-decentralized-event-coverage-24-x402 Title: LENSGRID · x402 Theme: Photography (photography) · crowdsourced photo event Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-density event photography layer where each image view or hi-res download is metered via x402. Attendees act as nodes, uploading captures that are pay-gated at 0.01 USDC. Organizers or fans pay-per-unlock to curate a real-time, high-fidelity gallery, instantly settling revenue to the photographer's Magic Link email sign-in without platform overhead. Why Hedera: Shifts the model from 'free crowdsourcing' to a 'micropayment-per-view' protocol. x402 eliminates the friction of bulk licensing, allowing users to pay only for the specific shots they want to see or use, incentivizing higher quality participation. Market: TAM $4.2B — The total addressable market for the gig economy and real-time content distribution in the age of programmable money. | SAM $850M — The global event photography and digital content licensing market, specifically focusing on social-heavy festivals and conferences. | SOM $12M — Web3-native summits and creator-led meetups where Hedera testnet facilitates low-friction micro-settlements for instant media access. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LENSGRID" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-density event photography layer where each image view or hi-res download is metered via x402. Attendees act as nodes, uploading captures that are pay-gated at 0.01 USDC. Organizers or fans pay-per-unlock to curate a real-time, high-fidelity gallery, instantly settling revenue to the photographer's Magic Link email sign-in without platform overhead. Discipline: Photography (crowdsourced photo event). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from 'free crowdsourcing' to a 'micropayment-per-view' protocol. x402 eliminates the friction of bulk licensing, allowing users to pay only for the specific shots they want to see or use, incentivizing higher quality participation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LENSGRID" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-chainframe-archive-0-x402 Title: ChainFrame · x402 Theme: Photography (photography) · photo archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-seal. Archive legacy media with cryptographic certainty. Users pay a micropayment to bind photo hashes to Base, creating an immutable proof-of-existence. Instead of monthly subscriptions, pay only for the storage you secure. Facilitators handle the gas, you manage the truth. Perfect for journalists and historians needing instant, verifiable provenance. Why Hedera: Shifts the model from a 'SaaS archive' to a 'metered verification engine'. By charging per-action rather than per-month, it lowers the barrier for high-volume, low-frequency archivists and enables automated agents to archive metadata flows on a per-image basis. Market: TAM $3.8B — Global digital asset management and long-term cloud archival storage market. | SAM $420M — The digital provenance and forensic photography market, including journalists and legal tech. | SOM $12M — Web3-native creators and professional photographers securing high-value IP on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ChainFrame" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-seal. Archive legacy media with cryptographic certainty. Users pay a micropayment to bind photo hashes to Base, creating an immutable proof-of-existence. Instead of monthly subscriptions, pay only for the storage you secure. Facilitators handle the gas, you manage the truth. Perfect for journalists and historians needing instant, verifiable provenance. Discipline: Photography (photo archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from a 'SaaS archive' to a 'metered verification engine'. By charging per-action rather than per-month, it lowers the barrier for high-volume, low-frequency archivists and enables automated agents to archive metadata flows on a per-image basis. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ChainFrame" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-pinphoto-journal-1-x402 Title: TrueLens · x402 Theme: Photography (photography) · photojournalism documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Provenance-as-a-Service for field reporters. A mobile toolkit that cryptographically seals RAW metadata and GPS coordinates to Base the moment a shutter clicks. Each 'Capture & Attest' event costs 0.01 USDC, providing an immutable audit trail for newsrooms to verify ground-truth media without subscription bloat. Why Hedera: By making verification a granular 0.01 USDC cost per photo, it aligns the cost of integrity directly with the volume of production, allowing freelance journalists to 'pay-as-they-attest' while newsrooms reimburse via on-chain expense logs. Market: TAM $12B — Global digital journalism infrastructure and newsroom technology market. | SAM $1.8B — Total addressable spend on digital rights management and content verification tools. | SOM $45M — On-chain attestation revenue from freelance photojournalists and independent news agencies. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueLens" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Provenance-as-a-Service for field reporters. A mobile toolkit that cryptographically seals RAW metadata and GPS coordinates to Base the moment a shutter clicks. Each 'Capture & Attest' event costs 0.01 USDC, providing an immutable audit trail for newsrooms to verify ground-truth media without subscription bloat. Discipline: Photography (photojournalism documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making verification a granular 0.01 USDC cost per photo, it aligns the cost of integrity directly with the volume of production, allowing freelance journalists to 'pay-as-they-attest' while newsrooms reimburse via on-chain expense logs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueLens" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-colorchain-palette-2-x402 Title: Chromesthesia · x402 Theme: Photography (photography) · color grading Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-lookup. A granular, high-fidelity color grading library for cinema and film. Instead of buying $100 preset packs, editors pay a micro-transaction to unlock the HEX-curve mapping of a specific frame. Every time a palette is imported into Resolve or Lightroom, the original colorist receives a direct USDC settlement. Professional-grade color science, metered by the pixel. Why Hedera: Transforming static asset libraries into a liquid, pay-per-use utility. x402 eliminates the friction of 'subscription fatigue' for niche assets, allowing creators to monetize individual grades that would otherwise be buried in a portfolio. Market: TAM $2.8B — The global photography software and post-production plugin market moving toward granular, cloud-based asset delivery. | SAM $450M — The digital asset market for professional photographers and video editors seeking specialized color science. | SOM $12M — The immediate niche of crypto-native videographers and AI-image generators requiring precise chromatic metadata. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chromesthesia" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-lookup. A granular, high-fidelity color grading library for cinema and film. Instead of buying $100 preset packs, editors pay a micro-transaction to unlock the HEX-curve mapping of a specific frame. Every time a palette is imported into Resolve or Lightroom, the original colorist receives a direct USDC settlement. Professional-grade color science, metered by the pixel. Discipline: Photography (color grading). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transforming static asset libraries into a liquid, pay-per-use utility. x402 eliminates the friction of 'subscription fatigue' for niche assets, allowing creators to monetize individual grades that would otherwise be buried in a portfolio. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chromesthesia" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-metaframe-vault-3-x402 Title: HardSign · x402 Theme: Photography (photography) · metadata embedding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity photo hardening tool that signs metadata and anchors it to IPFS via micropayments. Users pay 0.01 USDC per 'Sealing' event to immutably bind copyright, location, and equipment data to their work, generating a cryptographic proof of origin. Why Hedera: By moving away from subscription models, photographers only pay for what they shoot. Each 0.01 USDC transaction (x402) creates a clear, on-chain ledger of authorship that acts as a low-cost digital notary for professional assets. Market: TAM $4.2B — Global digital asset management and photography rights markets. | SAM $800M — Pro creators, journalists, and stock photographers requiring verified metadata. | SOM $12M — Web3-native photographers and decentralized media outlets using Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "HardSign" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity photo hardening tool that signs metadata and anchors it to IPFS via micropayments. Users pay 0.01 USDC per 'Sealing' event to immutably bind copyright, location, and equipment data to their work, generating a cryptographic proof of origin. Discipline: Photography (metadata embedding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving away from subscription models, photographers only pay for what they shoot. Each 0.01 USDC transaction (x402) creates a clear, on-chain ledger of authorship that acts as a low-cost digital notary for professional assets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "HardSign" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-snapshot-ledger-4-x402 Title: ShutterPress · x402 Theme: Photography (photography) · photo licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view image protocol where every hi-res render, metadata fetch, or commercial license print is a 0.01 USDC transaction. Instead of static watermarks, images are served as low-res previews that programmatically unlock via HTS transfer signatures. Creators receive instant settlement when a blog, AI model, or social post 'pings' the image for high-fidelity display. Why Hedera: Shifts licensing from a slow legal process to a real-time 'toll' for pixels. x402 allows for granular metering—charging per impression or per download—enabling a high-velocity stock photo economy powered by micro-transactions. Market: TAM $40.5B — Global digital rights management (DRM) and content licensing industry. | SAM $8.2B — The digital stock photography and stock footage market. | SOM $120M — Web3 native publishers, NFT platforms, and AI training sets requiring instant, verifiable provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ShutterPress" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view image protocol where every hi-res render, metadata fetch, or commercial license print is a 0.01 USDC transaction. Instead of static watermarks, images are served as low-res previews that programmatically unlock via HTS transfer signatures. Creators receive instant settlement when a blog, AI model, or social post 'pings' the image for high-fidelity display. Discipline: Photography (photo licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts licensing from a slow legal process to a real-time 'toll' for pixels. x402 allows for granular metering—charging per impression or per download—enabling a high-velocity stock photo economy powered by micro-transactions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ShutterPress" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-focusproof-network-5-x402 Title: FocusProof · x402 Theme: Photography (photography) · focus tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A forensic layer for professional photography. For 0.01 USDC, lock a high-resolution 'Focus Signature' into the Base ledger, attaching lens telemetry and focal plane depth to an image's hash. Buyers or publishers pay 0.01 USDC to verify the focus integrity, ensuring the image is optically captured and not a post-processed generative hallucination. Why Hedera: By turning focus metadata into a paid primitive, we create a barrier against AI-generated spam and a verifiable receipt for technical excellence. The micropayment acts as a 'digital notary' for optical truth. Market: TAM $2.1B — The global digital image integrity and provenance market by 2027. | SAM $450M — Professional photographers, forensic investigators, and stock photo platforms requiring verified metadata. | SOM $12M — On-chain journalism and high-end commercial photography agencies on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FocusProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A forensic layer for professional photography. For 0.01 USDC, lock a high-resolution 'Focus Signature' into the Base ledger, attaching lens telemetry and focal plane depth to an image's hash. Buyers or publishers pay 0.01 USDC to verify the focus integrity, ensuring the image is optically captured and not a post-processed generative hallucination. Discipline: Photography (focus tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning focus metadata into a paid primitive, we create a barrier against AI-generated spam and a verifiable receipt for technical excellence. The micropayment acts as a 'digital notary' for optical truth. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FocusProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-exposuretrace-6-x402 Title: ApertureLog · x402 Theme: Photography (photography) · exposure analysis Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-shot forensic layer for photographers. Pay 0.01 USDC to permanentize one frame's metadata—ISO, aperture, shutter speed, and focal point—into an HTS transfer signed on-chain ledger. Professional mentors and AI-tutors query this stream to provide real-time lighting corrections and technical feedback, paid via micro-tips per audit. Education becomes a metered stream of verified metadata rather than a PDF. Why Hedera: By shifting from 'storage' (IPFS) to 'metered logging' (x402), the act of tracking a shot becomes a granular economic event. This turns technical metadata into a tradeable asset for AI-assisted workflow auditing. Market: TAM $42B — The global digital photography and imaging software market, increasingly shifting toward automated provenance and mobile-first metadata tracking. | SAM $1.2B — Professional and hobbyist photographers using automated metadata workflows and AI-editing suites. | SOM $15M — Early adopters on Hedera using mobile-linked DSLRs/Mirrorless systems for real-time portfolio verification and technical peer-review. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ApertureLog" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-shot forensic layer for photographers. Pay 0.01 USDC to permanentize one frame's metadata—ISO, aperture, shutter speed, and focal point—into an HTS transfer signed on-chain ledger. Professional mentors and AI-tutors query this stream to provide real-time lighting corrections and technical feedback, paid via micro-tips per audit. Education becomes a metered stream of verified metadata rather than a PDF. Discipline: Photography (exposure analysis). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'storage' (IPFS) to 'metered logging' (x402), the act of tracking a shot becomes a granular economic event. This turns technical metadata into a tradeable asset for AI-assisted workflow auditing. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ApertureLog" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-momentmap-7-x402 Title: MomentMap · x402 Theme: Photography (photography) · location tagging Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Pin to the Permanent Atlas. Metadata is free, but geographic permanence is earned. MomentMap settles a 0.01 USDC micro-transaction via x402 to seal your photo’s coordinates to the Base-IPFS bridge. Every 'View' or 'Verify' call from third-party travel agents or historical archives triggers a micropayment back to the photographer, turning a map into a high-frequency royalty machine. Why Hedera: By putting the payment at the point of 'geographic signing,' the act of tagging becomes a proof-of-stake in a spatial data layer. x402 replaces the 'like' with a 'buy-in' to a verifiable global ledger. Market: TAM $1.2B — The total addressable market for verifiable, user-owned geospatial metadata in the AI training and travel sectors. | SAM $85M — The high-frequency location tagging and spatial data licensing market for autonomous entities. | SOM $4.2M — 420M yearly micro-signatures from mobile mobile photographers and IoT mapping sensors. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MomentMap" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Pin to the Permanent Atlas. Metadata is free, but geographic permanence is earned. MomentMap settles a 0.01 USDC micro-transaction via x402 to seal your photo’s coordinates to the Base-IPFS bridge. Every 'View' or 'Verify' call from third-party travel agents or historical archives triggers a micropayment back to the photographer, turning a map into a high-frequency royalty machine. Discipline: Photography (location tagging). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By putting the payment at the point of 'geographic signing,' the act of tagging becomes a proof-of-stake in a spatial data layer. x402 replaces the 'like' with a 'buy-in' to a verifiable global ledger. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MomentMap" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-collabochain-studio-8-x402 Title: Darkroom · x402 Theme: Photography (photography) · collaborative editing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: High-fidelity RAW photo collaboration where every layer adjustment, filter application, and version fork requires a 0.01 USDC micro-settlement. Eliminate subscription bloat; editors pay only for the compute and storage they consume, while creators earn instantly as collaborators 'unlock' high-res assets for retouching. Final exports are signed and settled on-chain. Why Hedera: By turning the 'edit' action into a billable event, the platform solves the 'freeloader' problem in creative workflows. It creates a granular ledger of who touched which pixels, using x402 to meter professional-grade cloud rendering and IPFS pinning. Market: TAM $44.1B — The global digital photography and photo editing software market. | SAM $1.4B — Professional photo editors and boutique post-production houses adopting pay-as-you-go cloud tools. | SOM $85M — On-chain creators and decentralized production DAOs early-adopting Base-native creative suites. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Darkroom" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT High-fidelity RAW photo collaboration where every layer adjustment, filter application, and version fork requires a 0.01 USDC micro-settlement. Eliminate subscription bloat; editors pay only for the compute and storage they consume, while creators earn instantly as collaborators 'unlock' high-res assets for retouching. Final exports are signed and settled on-chain. Discipline: Photography (collaborative editing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the 'edit' action into a billable event, the platform solves the 'freeloader' problem in creative workflows. It creates a granular ledger of who touched which pixels, using x402 to meter professional-grade cloud rendering and IPFS pinning. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Darkroom" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-nftrography-gallery-9-x402 Title: RAWGATE · x402 Theme: Photography (photography) · photo NFTs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity RAW stream where viewing is an act of settlement. Every high-res render or IPFS retrieval triggers a 0.01 USDC micro-royalty. Pro-grade provenance isn't just a mint; it's a metered gate where collectors pay decimal-dust to unlock uncompressed master files for display on digital canvases. Pay-per-view provenance for the high-end photography market. Why Hedera: Shifts NFT photography from static 'buy once' to 'pay-per-access' utility. By using x402, the app handles the micro-payments required for high-bandwidth IPFS retrieval, ensuring photographers are paid every time their work is actually viewed or utilized, not just sold. Market: TAM $4.5B — Global digital art and photography licensing economy. | SAM $1.2B — The professional stock and fine-art digital photography market moving toward on-chain licensing. | SOM $85M — Independent digital photographers and NFT collectors on Hedera using micro-transactions for high-res access. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RAWGATE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity RAW stream where viewing is an act of settlement. Every high-res render or IPFS retrieval triggers a 0.01 USDC micro-royalty. Pro-grade provenance isn't just a mint; it's a metered gate where collectors pay decimal-dust to unlock uncompressed master files for display on digital canvases. Pay-per-view provenance for the high-end photography market. Discipline: Photography (photo NFTs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts NFT photography from static 'buy once' to 'pay-per-access' utility. By using x402, the app handles the micro-payments required for high-bandwidth IPFS retrieval, ensuring photographers are paid every time their work is actually viewed or utilized, not just sold. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RAWGATE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-proofshot-certify-10-x402 Title: TrueLens · x402 Theme: Photography (photography) · image authentication Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-snap cryptographic sealing. Every shutter click generates an HTS transfer signed micropayment to mint a signed metadata proof on-chain, creating a tamper-proof trail for photojournalists and creators. No subscriptions; you only pay for the truth you verify. Why Hedera: By replacing subscriptions with a per-image x402 cost, high-volume forensic users and casual creators alike can anchor authenticity without monthly overhead. The friction of payment matches the intentionality of 'proof'. Market: TAM $1.8B — the global digital trust and image authentication market for AI-generated vs. organic content. | SAM $120M — professional photographers, insurance adjusters, and citizen journalists globally. | SOM $4.5M — Base-native creators and digital forensics professionals requiring immutable image provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueLens" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-snap cryptographic sealing. Every shutter click generates an HTS transfer signed micropayment to mint a signed metadata proof on-chain, creating a tamper-proof trail for photojournalists and creators. No subscriptions; you only pay for the truth you verify. Discipline: Photography (image authentication). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing subscriptions with a per-image x402 cost, high-volume forensic users and casual creators alike can anchor authenticity without monthly overhead. The friction of payment matches the intentionality of 'proof'. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueLens" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-chronocapture-log-11-x402 Title: ChronoLog · x402 Theme: Photography (photography) · time lapse Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular time-lapse protocol where every frame is a validated state update. Content creators pay 0.01 USDC per frame commit to IPFS via the facilitator, while viewers pay per-frame or per-sequence to unlock high-resolution renders. Perfect for construction audits, botanical studies, and viral 'growth' content where provenance and permanence are gated by micropayments. Why Hedera: By turning the frame-upload into a metered transaction, we prevent spam and ensure the storage costs are subsidised by the user. The x402 primitive acts as a 'shutter click' fee, turning the act of capturing time into a verifiable financial event on Hedera. Market: TAM $3.2B — The global time-lapse and long-term project monitoring industry. | SAM $150M — The decentralised storage and digital archiving market for high-frequency visual data. | SOM $12M — Web3 creators and industrial monitoring firms requiring immutable, pay-as-you-go visual logs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ChronoLog" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular time-lapse protocol where every frame is a validated state update. Content creators pay 0.01 USDC per frame commit to IPFS via the facilitator, while viewers pay per-frame or per-sequence to unlock high-resolution renders. Perfect for construction audits, botanical studies, and viral 'growth' content where provenance and permanence are gated by micropayments. Discipline: Photography (time lapse). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the frame-upload into a metered transaction, we prevent spam and ensure the storage costs are subsidised by the user. The x402 primitive acts as a 'shutter click' fee, turning the act of capturing time into a verifiable financial event on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ChronoLog" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-lenslegacy-vault-12-x402 Title: LensLegacy · x402 Theme: Photography (photography) · camera data archival Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized sensor-telemetry archive for vintage optics. Pay 0.01 USDC to commit a high-fidelity 'Sensor Snapshot' (EXIF, ISO noise profiles, and RAW metadata) to permanent storage. Photographers and AI training labs pay the vault per query to access longitudinal performance data for specific legacy glass. Payment triggers the archival hash. Why Hedera: By turning data entry into a micro-transaction, you prevent spam while creating a peer-to-peer data market for gear nerds. The HTS transfer flow ensures that every piece of metadata is 'paid for' and verified, treating camera data as a financial asset. Market: TAM $5.2B — The global photography and imaging market, increasingly reliant on high-fidelity training data for computational photography. | SAM $850M — The digital asset management and metadata services market for professional photographers and archivists. | SOM $12M — Specialized archival for vintage lens collectors and developers building AI-driven optical correction profiles. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LensLegacy" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized sensor-telemetry archive for vintage optics. Pay 0.01 USDC to commit a high-fidelity 'Sensor Snapshot' (EXIF, ISO noise profiles, and RAW metadata) to permanent storage. Photographers and AI training labs pay the vault per query to access longitudinal performance data for specific legacy glass. Payment triggers the archival hash. Discipline: Photography (camera data archival). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning data entry into a micro-transaction, you prevent spam while creating a peer-to-peer data market for gear nerds. The HTS transfer flow ensures that every piece of metadata is 'paid for' and verified, treating camera data as a financial asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LensLegacy" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-frameswap-marketplace-13-x402 Title: ISO-Unlock · x402 Theme: Photography (photography) · photo asset exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity RAW asset vault where every preview image is free, but the decryption of the high-res IPFS source and its permanent license pinning is a 0.01 USDC event. No subscriptions—photographers get paid instantly via x402 micropayments the moment a creator 'unlocks' the asset for their project. Why Hedera: By commoditizing the 'unlock' rather than the 'access,' it removes friction for creators to browse and only pay for exactly what they use, while ensuring photographers receive non-custodial, real-time settlement on Hedera. Market: TAM $10.5B — Global stock photography and digital asset licensing market. | SAM $450M — Modern digital asset marketplaces and micro-licensing platforms. | SOM $12M — Decentralized creators using on-chain attribution and RAW workflows on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ISO-Unlock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity RAW asset vault where every preview image is free, but the decryption of the high-res IPFS source and its permanent license pinning is a 0.01 USDC event. No subscriptions—photographers get paid instantly via x402 micropayments the moment a creator 'unlocks' the asset for their project. Discipline: Photography (photo asset exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By commoditizing the 'unlock' rather than the 'access,' it removes friction for creators to browse and only pay for exactly what they use, while ensuring photographers receive non-custodial, real-time settlement on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ISO-Unlock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-clipstatic-cdn-14-x402 Title: ShutterFlow · x402 Theme: Photography (photography) · image delivery Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular CDN for creative assets. Instead of monthly bandwidth tiers, users pay 0.01 USDC per asset retrieval. Photographers and devs embed high-res IPFS links that only resolve upon a signed HTS transfer micro-transaction, ensuring creators are paid for every single impression or download without platform overhead. Why Hedera: Traditional CDNs rely on subscription bulk-pricing which penalizes low-volume creators. Scaling by 'request-as-a-transaction' aligns infrastructure cost directly with asset value. Market: TAM $18.5B — The global Content Delivery Network (CDN) market moving toward edge-computing and micro-billing. | SAM $400M — Projected spend by decentralized application (dApp) developers on high-availability asset delivery/gatekeeping. | SOM $12M — Independent photographers and NFT creators requiring per-view monetization on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ShutterFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular CDN for creative assets. Instead of monthly bandwidth tiers, users pay 0.01 USDC per asset retrieval. Photographers and devs embed high-res IPFS links that only resolve upon a signed HTS transfer micro-transaction, ensuring creators are paid for every single impression or download without platform overhead. Discipline: Photography (image delivery). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional CDNs rely on subscription bulk-pricing which penalizes low-volume creators. Scaling by 'request-as-a-transaction' aligns infrastructure cost directly with asset value. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ShutterFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-tokenizelight-15-x402 Title: PhotonGate · x402 Theme: Photography (photography) · rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless rights-server for high-resolution assets. Creators host photos on IPFS but gate the decryption key behind an x402 endpoint. Want to use a photo for a blog post or training data? Sign the HTS transfer request. 0.01 USDC per resolution upgrade or commercial license ping. Payment is the unlock; the Hedera transaction id is your receipt of usage rights. Why Hedera: By shifting from 'tokenizing' to 'pay-per-view/use,' we eliminate the friction of NFT marketplaces. The 0.01 USDC micropayment turns every photo into a metered API, perfect for AI scrapers or micro-publishers who need legal proof of payment without the overhead of enterprise licensing. Market: TAM $4.8B — Global stock photography and digital rights management market. | SAM $1.2B — The visual content licensing market moving toward programmatic, high-volume micro-usage. | SOM $15M — Early-stage adoption by AI training aggregators and indie digital publishers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PhotonGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless rights-server for high-resolution assets. Creators host photos on IPFS but gate the decryption key behind an x402 endpoint. Want to use a photo for a blog post or training data? Sign the HTS transfer request. 0.01 USDC per resolution upgrade or commercial license ping. Payment is the unlock; the Hedera transaction id is your receipt of usage rights. Discipline: Photography (rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'tokenizing' to 'pay-per-view/use,' we eliminate the friction of NFT marketplaces. The 0.01 USDC micropayment turns every photo into a metered API, perfect for AI scrapers or micro-publishers who need legal proof of payment without the overhead of enterprise licensing. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PhotonGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-hdr-pinboard-16-x402 Title: LumenArchive · x402 Theme: Photography (photography) · high dynamic range Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Luminance data is too heavy for legacy clouds. Pay-per-burst to push bracketed RAW sets to a permanent HDR vault. $0.01 per frame to unlock verifiable, high-bit-depth assets for professional compositing or training Neural Radiance Fields. Payment triggers the IPFS pinning service and signs the metadata hash. Why Hedera: By moving the cost of storage and verification to a per-image micropayment, photographers can monetize high-fidelity assets at the granular level. The x402 model ensures that every 'pin' is a paid, on-chain event, turning a simple gallery into a metered data pipeline for HDR content collectors and AI researchers. Market: TAM $2.4B — The global digital asset management and HDR imaging software market. | SAM $420M — Professional photographers, VFX studios, and 3D environment artists requiring high-bit-depth source material. | SOM $8.5M — Crypto-native HDR photographers and neural-rendering researchers using Base for asset provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LumenArchive" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Luminance data is too heavy for legacy clouds. Pay-per-burst to push bracketed RAW sets to a permanent HDR vault. $0.01 per frame to unlock verifiable, high-bit-depth assets for professional compositing or training Neural Radiance Fields. Payment triggers the IPFS pinning service and signs the metadata hash. Discipline: Photography (high dynamic range). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving the cost of storage and verification to a per-image micropayment, photographers can monetize high-fidelity assets at the granular level. The x402 model ensures that every 'pin' is a paid, on-chain event, turning a simple gallery into a metered data pipeline for HDR content collectors and AI researchers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LumenArchive" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-cropchain-editor-17-x402 Title: GoldenRatio · x402 Theme: Photography (photography) · image cropping Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for high-fidelity aspect ratio presets and deterministic cropping. Pay 0.01 USDC per edit to settle the crop coordinates, filter metadata, and IPFS hash on-chain, ensuring every derivative works' provenance is immutable and billable. Why Hedera: By turning a simple utility into a paid primitive, you create a ledger of image transformations. Creators pay per crop to 'mint' the valid version of an asset, while AI training sets pay to access these human-curated coordinate sets. Market: TAM $4.2B — Global digital image processing and metadata management market. | SAM $450M — Revenue from professional photo editing tools and decentralized storage gateways. | SOM $12M — Web3 creators and NFT photographers securing composition metadata on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GoldenRatio" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for high-fidelity aspect ratio presets and deterministic cropping. Pay 0.01 USDC per edit to settle the crop coordinates, filter metadata, and IPFS hash on-chain, ensuring every derivative works' provenance is immutable and billable. Discipline: Photography (image cropping). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning a simple utility into a paid primitive, you create a ledger of image transformations. Creators pay per crop to 'mint' the valid version of an asset, while AI training sets pay to access these human-curated coordinate sets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GoldenRatio" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-pixelproof-sharing-18-x402 Title: ClearVue · x402 Theme: Photography (photography) · image watermarking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity proofing engine where clients pay-per-view to de-blur or remove watermarks from high-res asset previews. Photographers upload to IPFS, and each 'reveal' triggers a 0.01 USDC x402 stream to the creator. No more manual invoicing for small-batch selection; the metadata unlock is the transaction. Why Hedera: By moving from static protection to 'metered visibility,' x402 turns the proofing process into a high-velocity revenue stream. It eliminates the friction of bulk licensing for clients who only need specific shots, while ensuring creators are compensated for every single impression of their raw work. Market: TAM $4.2B — The global digital rights management (DRM) and image licensing market. | SAM $450M — Independent photographers and digital artists using web3 rails for asset management. | SOM $12M — Early adopters in the event and wedding photography space requiring instant proof-delivery systems. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ClearVue" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity proofing engine where clients pay-per-view to de-blur or remove watermarks from high-res asset previews. Photographers upload to IPFS, and each 'reveal' triggers a 0.01 USDC x402 stream to the creator. No more manual invoicing for small-batch selection; the metadata unlock is the transaction. Discipline: Photography (image watermarking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from static protection to 'metered visibility,' x402 turns the proofing process into a high-velocity revenue stream. It eliminates the friction of bulk licensing for clients who only need specific shots, while ensuring creators are compensated for every single impression of their raw work. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ClearVue" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-filterchain-exchange-19-x402 Title: LENSFLUX · x402 Theme: Photography (photography) · filter sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized RAW-to-look marketplace where photographers license custom LUTs and Lightroom presets per-export. Instead of buying packs, users and AI-editing agents pay 0.01 USDC to pull a specific CID from IPFS and apply the metadata to an image. Creators earn instant, granular royalties for every frame processed by their aesthetic style. Why Hedera: Shifts photography from static asset sales to a 'metered style' utility, enabling AI agents to programmatically apply human-curated presets. Market: TAM $4.1B — The global digital photo editing and filter software market. | SAM $240M — Professional mobile photographers and social media creators using metered editing tools. | SOM $12M — Web3 native creators and automated photo-processing bots on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LENSFLUX" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized RAW-to-look marketplace where photographers license custom LUTs and Lightroom presets per-export. Instead of buying packs, users and AI-editing agents pay 0.01 USDC to pull a specific CID from IPFS and apply the metadata to an image. Creators earn instant, granular royalties for every frame processed by their aesthetic style. Discipline: Photography (filter sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts photography from static asset sales to a 'metered style' utility, enabling AI agents to programmatically apply human-curated presets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LENSFLUX" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-storyframe-ledger-20-x402 Title: StoryFrame · x402 Theme: Photography (photography) · photo storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: 0.01 USDC to commit a story-manifest to the Ledger. Users pay to anchor multi-image sequences to Base, ensuring sequence integrity and creator provenance. This turns every photo narrative into a metered, verifiable digital asset that agents can license or viewers can unlock. Why Hedera: By moving from a free IPFS upload to a paid x402 anchor, every 'manifest' becomes a financial transaction. The micropayment acts as a spam filter for the ledger and a standard fee for permanent sequencing, enabling a 'pay-per-story' architecture for professional photojournalists and AI curators. Market: TAM $4.2B — Global creators and archival platforms moving toward decentralized metadata standards. | SAM $740M — The digital asset management and blockchain provenance sub-sector for visual media. | SOM $12M — On-chain photojournalism and archival storytelling on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StoryFrame" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT 0.01 USDC to commit a story-manifest to the Ledger. Users pay to anchor multi-image sequences to Base, ensuring sequence integrity and creator provenance. This turns every photo narrative into a metered, verifiable digital asset that agents can license or viewers can unlock. Discipline: Photography (photo storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a free IPFS upload to a paid x402 anchor, every 'manifest' becomes a financial transaction. The micropayment acts as a spam filter for the ledger and a standard fee for permanent sequencing, enabling a 'pay-per-story' architecture for professional photojournalists and AI curators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StoryFrame" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-depthmap-chain-21-x402 Title: Parallax · x402 Theme: Photography (photography) · 3D photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.05 USDC to algorithmically extract and anchor a sub-millimeter depth map from any 2D image. The protocol converts flat JPGs into spatial assets, returning a permanent IPFS CID and a Hedera transaction id. Hardware-agnostic volumetric preservation for the Apple Vision Pro era, priced per extraction. Why Hedera: Traditional 3D archiving is locked in proprietary silos. By moving to a pay-per-extraction model via x402, legacy photos become liquid spatial assets. The micropayment gates the GPU-heavy depth inference, turning 'DepthMap' from a storage tool into a high-utility spatial engine. Market: TAM $2.8B — Global digital photography and computational imaging market moving toward 'Spatial' standards. | SAM $450M — The emerging market for spatial computing assets, 3D printing, and AR/VR enthusiast tooling. | SOM $12M — Pro-sumers and legacy photo archivists transitioning 2D libraries to spatial formats via pay-per-render APIs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Parallax" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.05 USDC to algorithmically extract and anchor a sub-millimeter depth map from any 2D image. The protocol converts flat JPGs into spatial assets, returning a permanent IPFS CID and a Hedera transaction id. Hardware-agnostic volumetric preservation for the Apple Vision Pro era, priced per extraction. Discipline: Photography (3D photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional 3D archiving is locked in proprietary silos. By moving to a pay-per-extraction model via x402, legacy photos become liquid spatial assets. The micropayment gates the GPU-heavy depth inference, turning 'DepthMap' from a storage tool into a high-utility spatial engine. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Parallax" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-framesplit-archive-22-x402 Title: LayerLoom · x402 Theme: Photography (photography) · photo segmentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Layer-level provenance for high-res imagery. Sign to isolate subjects, background, or textures into distinct, composable IPFS assets. Pay-per-slice to extract production-ready masks. Every segmentation results in a permanent, verifiable layer available for global remixing, creating a paid graph of visual components. Why Hedera: By turning segmentation into a paid primitive, the 'archive' becomes a specialized liquid marketplace of assets rather than a static gallery. Creators pay to use the compute-heavy segmentation tool, then receive micro-royalties when those specific layers are pulled into new compositions. Market: TAM $6.8B — The global computer vision and image processing software market. | SAM $180M — The digital asset & stock photo marketplace (paying per element rather than per full image). | SOM $4.2M — Base-native creative workflows utilizing autonomous AI agents and remix-heavy social protocols. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LayerLoom" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Layer-level provenance for high-res imagery. Sign to isolate subjects, background, or textures into distinct, composable IPFS assets. Pay-per-slice to extract production-ready masks. Every segmentation results in a permanent, verifiable layer available for global remixing, creating a paid graph of visual components. Discipline: Photography (photo segmentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning segmentation into a paid primitive, the 'archive' becomes a specialized liquid marketplace of assets rather than a static gallery. Creators pay to use the compute-heavy segmentation tool, then receive micro-royalties when those specific layers are pulled into new compositions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LayerLoom" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-batchpin-manager-23-x402 Title: BatchPin · x402 Theme: Photography (photography) · bulk image processing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-pin storage. BatchPin eliminates monthly SaaS subscriptions for photographers. Sign a single HTS transfer permit to authorize a batch upload; the facilitator settles the USDC micropayment and returns a Hedera transaction id containing the CID. Direct, permanent, and metered storage for high-res archives without the overhead of enterprise IPFS gateway contracts. Why Hedera: Traditional IPFS pinning services require high-friction monthly subscriptions. By turning every 'PUT' request into a $0.01 x402 transaction, the app serves the long tail of freelance photographers who need occasional, immutable storage without recurring costs. Market: TAM $4.2B — The global digital asset management (DAM) and cloud storage market transitioning to verifiable web3 infrastructure. | SAM $850M — The decentralized storage and web3-native creator economy, specifically targeting developers and photographers using IPFS/Filecoin ecosystems. | SOM $12M — Web3 photographers and dApp developers requiring programmatic, pay-as-you-go pinning on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "BatchPin" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-pin storage. BatchPin eliminates monthly SaaS subscriptions for photographers. Sign a single HTS transfer permit to authorize a batch upload; the facilitator settles the USDC micropayment and returns a Hedera transaction id containing the CID. Direct, permanent, and metered storage for high-res archives without the overhead of enterprise IPFS gateway contracts. Discipline: Photography (bulk image processing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional IPFS pinning services require high-friction monthly subscriptions. By turning every 'PUT' request into a $0.01 x402 transaction, the app serves the long tail of freelance photographers who need occasional, immutable storage without recurring costs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "BatchPin" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-mirrorframe-backup-24-x402 Title: MirrorFrame · x402 Theme: Photography (photography) · image backup Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-redundancy layer for onchain memories. Pay $0.01 USDC to trigger a localized pin-and-propagate event across high-availability IPFS nodes, ensuring your media survivors long after gateways go dark. Why Hedera: Traditional storage relies on monthly subscriptions or one-time high fees. MirrorFrame uses x402 to meter the specific 'act of preservation,' turning backup into a high-frequency, low-friction micro-transaction every time a user saves a memory. Market: TAM $14B — The global cloud storage and data preservation market shifting toward decentralized, pay-as-you-go models. | SAM $850M — Addressing enthusiasts and professional photographers using Web3 storage protocols needing per-asset redundancy. | SOM $12M — Initial reach via Base-native social apps (Farcaster/Lens) where users want persistent media backups. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MirrorFrame" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-redundancy layer for onchain memories. Pay $0.01 USDC to trigger a localized pin-and-propagate event across high-availability IPFS nodes, ensuring your media survivors long after gateways go dark. Discipline: Photography (image backup). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional storage relies on monthly subscriptions or one-time high fees. MirrorFrame uses x402 to meter the specific 'act of preservation,' turning backup into a high-frequency, low-friction micro-transaction every time a user saves a memory. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MirrorFrame" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-gasless-gallery-0-x402 Title: Snapshot · x402 Theme: Photography (photography) · photo sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity photo gallery where every 'view' or 'high-res download' triggers a 0.01 USDC micro-settlement directly to the creator. No gas, no subscriptions—just pure pay-per-view access secured by HTS transfer. Audience members pay a penny to unlock exclusive shots, and collaborators split royalties instantly per session. Why Hedera: Shifting from 'gasless' (free) to 'x402' (micro-paid) turns a cost center into a revenue engine. It leverages the psychological ease of a $0.01 price point to monetize professional photography without the friction of a paywall or the bloat of an NFT mint. Market: TAM $12B — The global digital photography and stock image market transitioning to web3 micropayment rails. | SAM $450M — The addressable market for enthusiast photographers and digital creators seeking alternatives to ad-based social media. | SOM $12M — Early adopters in the Base ecosystem and onchain creator communities using HashPack for frictionless onboarding. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Snapshot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity photo gallery where every 'view' or 'high-res download' triggers a 0.01 USDC micro-settlement directly to the creator. No gas, no subscriptions—just pure pay-per-view access secured by HTS transfer. Audience members pay a penny to unlock exclusive shots, and collaborators split royalties instantly per session. Discipline: Photography (photo sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from 'gasless' (free) to 'x402' (micro-paid) turns a cost center into a revenue engine. It leverages the psychological ease of a $0.01 price point to monetize professional photography without the friction of a paywall or the bloat of an NFT mint. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Snapshot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-nft-photo-stories-1-x402 Title: Witness · x402 Theme: Photography (photography) · photojournalism Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view lens for high-stakes reportage. Photojournalists publish raw, high-resolution stories where every image expand and metadata reveal is gated by a 0.01 USDC micropayment. No subscriptions or ads—just a direct, metered relationship between the witness and the audience. the embedded wallet handles the seamless signing; x402 handles the instant attribution. Every 'look' is a transaction that funds the front lines. Why Hedera: Traditional media is dying because ads are intrusive and subscriptions are too high-friction for single stories. By making the unit of consumption a $0.01 'reveal' call, photojournalists can monetize viral single-frame moments and deep-dive sequences instantly on-chain without gatekeepers. Market: TAM $14B — The global digital news and stock photography licensing market transitioning to agentic, micro-metered consumption. | SAM $850M — The addressable spend from independent news consumers and 'citizen journalism' supporters on decentralized social protocols (Lens, Farcaster). | SOM $12M — On-chain native users and war-correspondent followers paying for exclusive, high-fidelity visual evidence via 1-click HashPack-signed unlocks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Witness" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view lens for high-stakes reportage. Photojournalists publish raw, high-resolution stories where every image expand and metadata reveal is gated by a 0.01 USDC micropayment. No subscriptions or ads—just a direct, metered relationship between the witness and the audience. the embedded wallet handles the seamless signing; x402 handles the instant attribution. Every 'look' is a transaction that funds the front lines. Discipline: Photography (photojournalism). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional media is dying because ads are intrusive and subscriptions are too high-friction for single stories. By making the unit of consumption a $0.01 'reveal' call, photojournalists can monetize viral single-frame moments and deep-dive sequences instantly on-chain without gatekeepers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Witness" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-chaincolor-study-2-x402 Title: ChromaPass · x402 Theme: Photography (photography) · color grading Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Professional colorists and mobile photographers pay 0.01 USDC to unlock cinema-grade LUTs and RAW grading profiles. Instead of monthly subscriptions, you pay per export. Creators earn instant USDC for every frame their style processes. A high-fidelity marketplace where every 'Apply' is a micro-settlement. Why Hedera: The legacy model of buying $50 preset packs is dead. x402 enables a 'pay-per-look' economy that lowers the barrier for hobbyists while providing continuous, metered revenue for professional colorists. Market: TAM $2.1B — Total global expenditure on post-production visual effects and color services. | SAM $450M — The market for premium image editing software and mobile filter assets. | SOM $18M — Targeted reach of enthusiast mobile photographers and Base-native creative agents. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ChromaPass" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Professional colorists and mobile photographers pay 0.01 USDC to unlock cinema-grade LUTs and RAW grading profiles. Instead of monthly subscriptions, you pay per export. Creators earn instant USDC for every frame their style processes. A high-fidelity marketplace where every 'Apply' is a micro-settlement. Discipline: Photography (color grading). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: The legacy model of buying $50 preset packs is dead. x402 enables a 'pay-per-look' economy that lowers the barrier for hobbyists while providing continuous, metered revenue for professional colorists. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ChromaPass" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-gas-free-prints-order-3-x402 Title: Gloss · x402 Theme: Photography (photography) · print ordering Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity physical output layer for mobile photography. Pay 0.01 USDC to instantly lock a high-res asset into the global print queue via x402. Payment triggers an automated production relay, bypassing traditional cart checkouts for a 'one-tap-to-paper' experience. No subscriptions, just a micro-fee per frame sent to the press. Why Hedera: By turning the 'order' action into an x402-metered call, we remove the friction of traditional e-commerce. Each payment acts as a cryptographic commitment to the print queue, allowing for millisecond confirmation and automated fulfillment routing without gas overhead. Market: TAM $4.2B — The global personalized photo printing and merchandise market shifting toward frictionless, wallet-integrated commerce. | SAM $140M — The addressable market for on-demand specialty printing triggered by digital micro-transactions and AI-generated art fulfillment. | SOM $8.5M — Initial capture of mobile-native photographers and Web3 event organizers providing instant physical souvenirs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Gloss" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity physical output layer for mobile photography. Pay 0.01 USDC to instantly lock a high-res asset into the global print queue via x402. Payment triggers an automated production relay, bypassing traditional cart checkouts for a 'one-tap-to-paper' experience. No subscriptions, just a micro-fee per frame sent to the press. Discipline: Photography (print ordering). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the 'order' action into an x402-metered call, we remove the friction of traditional e-commerce. Each payment acts as a cryptographic commitment to the print queue, allowing for millisecond confirmation and automated fulfillment routing without gas overhead. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Gloss" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-privy-photo-bounties-4-x402 Title: SnapGate · x402 Theme: Photography (photography) · community curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn your gallery into a premium source for digital assets. Brands and curators post prompt-based bounties (e.g., 'Street Art in Tokyo'). Every time a user submits or views a high-res entry, a 0.01 USDC x402 payment settles instantly to the creator. No subscriptions or manual payouts—just a metered stream of curation where every interaction is a settlement. Why Hedera: Replacing 'rewards' with instant micropayments creates a high-velocity feedback loop. By metering the view/submission process, the app becomes a real-time marketplace for imagery rather than a slow, centralized contest. Market: TAM $4.2B — Global stock photography and crowdsourced content creation markets moving toward automated licensing. | SAM $240M — The emerging market for AI training data sets and micro-licensed mobile photography. | SOM $18M — The niche for high-fidelity, verified onchain imagery for web3 brands and decentralized social apps. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SnapGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn your gallery into a premium source for digital assets. Brands and curators post prompt-based bounties (e.g., 'Street Art in Tokyo'). Every time a user submits or views a high-res entry, a 0.01 USDC x402 payment settles instantly to the creator. No subscriptions or manual payouts—just a metered stream of curation where every interaction is a settlement. Discipline: Photography (community curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Replacing 'rewards' with instant micropayments creates a high-velocity feedback loop. By metering the view/submission process, the app becomes a real-time marketplace for imagery rather than a slow, centralized contest. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SnapGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-instant-rights-ledger-5-x402 Title: SnapProof · x402 Theme: Photography (photography) · rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An automated licensing gate where users pay 0.01 USDC to instantly unlock high-res metadata and usage rights for any image, with the fee streaming directly to the creator's wallet. Rights aren't just registered; they are metered and enforced at the point of consumption. Why Hedera: By shifting from 'registration' to 'per-view/per-use' licensing, creators monetize the actual utility of their work. x402 eliminates the friction of traditional licensing contracts by making the payment the legal 'signature' and the unlock trigger. Market: TAM $4.2B — Global digital rights management and stock photography market. | SAM $450M — High-frequency digital content creators and freelance photographers requiring instant, micro-licensing models. | SOM $12M — AI training dataset scrapers and blog publishers requiring verifiable, micro-paid image rights on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SnapProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An automated licensing gate where users pay 0.01 USDC to instantly unlock high-res metadata and usage rights for any image, with the fee streaming directly to the creator's wallet. Rights aren't just registered; they are metered and enforced at the point of consumption. Discipline: Photography (rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'registration' to 'per-view/per-use' licensing, creators monetize the actual utility of their work. x402 eliminates the friction of traditional licensing contracts by making the payment the legal 'signature' and the unlock trigger. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SnapProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-gasless-metadata-tags-6-x402 Title: TAGS · x402 Theme: Photography (photography) · photo metadata Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-throughput API for immutable image provenance. Pay 0.01 USDC to write cryptographically verified EXIF and ownership data to the Base ledger. Perfect for mobile apps and AI scrapers requiring instant, micro-fee attestation without holding native GAS. Payment triggers the instant HTS transfer transfer and metadata commit. Why Hedera: By moving from 'gasless' (which usually implies a subsidy) to 'micropayment,' we turn metadata into a billable utility. x402 eliminates the friction of gas fees by using USDC to cover the operational cost of the onchain write in one signature. Market: TAM $2.4B (Global digital asset management and metadata security market). | SAM $115M (Professional mobile photographers and digital asset managers using Base). | SOM $9.5M (Initial integration with decentralized social apps and NFT minting front-ends). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TAGS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-throughput API for immutable image provenance. Pay 0.01 USDC to write cryptographically verified EXIF and ownership data to the Base ledger. Perfect for mobile apps and AI scrapers requiring instant, micro-fee attestation without holding native GAS. Payment triggers the instant HTS transfer transfer and metadata commit. Discipline: Photography (photo metadata). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'gasless' (which usually implies a subsidy) to 'micropayment,' we turn metadata into a billable utility. x402 eliminates the friction of gas fees by using USDC to cover the operational cost of the onchain write in one signature. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TAGS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-sponsored-tx-photo-sales-7-x402 Title: ISO · x402 Theme: Photography (photography) · photo marketplace Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency street photography feed where every high-res view or 'Save to Vault' action is a micro-transaction. Instead of complex NFT mints, users pay 0.01 USDC to instantly decrypt and own the right to use a photo. Creators receive instant liquid settlement every time their work is viewed or used in a digital layout, turning your portfolio into a metered API for visual assets. Why Hedera: Traditional photo marketplaces suffer from high friction and high unit prices. By shifting to an x402 'pay-per-view/save' model, we lower the barrier to entry to $0.01, allowing for a high-velocity 'TikTok-style' consumption of professional photography where the creator is paid for every single interaction. Market: TAM $4.2B — The global stock photography and digital asset licensing market, pivotable toward micro-metered consumption. | SAM $450M — The segment of digital content consumers and small-scale creators using Base and similar L2s for micro-transactions. | SOM $12M — Early adopters in the Farcaster and Lens ecosystems looking for frictionless, instant-pay visual assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ISO" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency street photography feed where every high-res view or 'Save to Vault' action is a micro-transaction. Instead of complex NFT mints, users pay 0.01 USDC to instantly decrypt and own the right to use a photo. Creators receive instant liquid settlement every time their work is viewed or used in a digital layout, turning your portfolio into a metered API for visual assets. Discipline: Photography (photo marketplace). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional photo marketplaces suffer from high friction and high unit prices. By shifting to an x402 'pay-per-view/save' model, we lower the barrier to entry to $0.01, allowing for a high-velocity 'TikTok-style' consumption of professional photography where the creator is paid for every single interaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ISO" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-privy-nft-experiments-8-x402 Title: Shutter · x402 Theme: Photography (photography) · photo NFT art Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Peer-to-peer image licensing for the agentic web. Bypass marketplace bloat by turning photos into HTS transfer metered assets. Fans or AI agents pay 0.01 USDC to unlock high-res downloads or commercial usage rights, with instant Base settlement directly to the creator's Magic Link email sign-in. Payment is the shutter click. Why Hedera: By shifting from 'minting' (speculation) to 'per-view/per-use' (utility), the app captures high-frequency micro-revenue that NFT marketplaces miss. x402 allows for granular monetization of digital catalogs where every 'save-as' requires a signed authorization. Market: TAM $4.2B — The global stock photography and digital rights management market. | SAM $450M — Revenue from stock photo licensing and creator-direct digital downloads transitioning to on-chain rails. | SOM $12M — Early adopters in the on-chain photography space and AI agents requiring licensed training data/visuals. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Shutter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Peer-to-peer image licensing for the agentic web. Bypass marketplace bloat by turning photos into HTS transfer metered assets. Fans or AI agents pay 0.01 USDC to unlock high-res downloads or commercial usage rights, with instant Base settlement directly to the creator's Magic Link email sign-in. Payment is the shutter click. Discipline: Photography (photo NFT art). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'minting' (speculation) to 'per-view/per-use' (utility), the app captures high-frequency micro-revenue that NFT marketplaces miss. x402 allows for granular monetization of digital catalogs where every 'save-as' requires a signed authorization. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Shutter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-gas-free-collaboration-9-x402 Title: ProofSheet · x402 Theme: Photography (photography) · team workflow Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Kill the seat-based SaaS model for studios. Every high-res export, RAW file access, or preset application costs exactly 0.01 USDC. Freelancers and agencies collaborate in a shared workspace where the 'gas-free' experience is subsidized by granular, per-action micro-billing. Pay for the edit, not the overhead. Why Hedera: By moving from a subscription to a metered x402 model, photo teams eliminate 'zombie seats' and align costs directly with production output. the embedded wallet-signed HTS transfer permits authorized batch exports without constant wallet popups. Market: TAM $4.2B — The global digital asset management and collaborative photo editing software market. | SAM $850M — Professional photography and retouching agencies utilizing cloud collaboration tools. | SOM $12M — Web3-native creative studios and decentralized media collectives on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ProofSheet" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Kill the seat-based SaaS model for studios. Every high-res export, RAW file access, or preset application costs exactly 0.01 USDC. Freelancers and agencies collaborate in a shared workspace where the 'gas-free' experience is subsidized by granular, per-action micro-billing. Pay for the edit, not the overhead. Discipline: Photography (team workflow). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a subscription to a metered x402 model, photo teams eliminate 'zombie seats' and align costs directly with production output. the embedded wallet-signed HTS transfer permits authorized batch exports without constant wallet popups. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ProofSheet" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-onchain-event-albums-10-x402 Title: Flashback · x402 Theme: Photography (photography) · event photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: High-fidelity event photography metered by the shutter. Attendees or hosts pay 0.01 USDC per high-res download or social unlock via HTS transfer. Eliminate bulk package friction; users pay only for the memories they keep. Facilitators settle transfers instantly, allowing photographers to monetize individual 'hero shots' in real-time as they hit the chain. Why Hedera: Traditional event photography relies on expensive flat fees or clumsy watermarked galleries. By turning every 'save' into a 0.01 USDC micro-transaction, you convert passive viewers into active buyers, removing the psychological barrier of $20 digital downloads. Market: TAM $4.2B — The global event photography market shifting toward near-instant digital distribution and micro-monetization. | SAM $850M — The share of event photography accessible via digital micro-galleries and web3-native social platforms. | SOM $12M — Early adopters at crypto conferences (Devcon, ETHGlobal) using per-photo unlocks for immediate social proof. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Flashback" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT High-fidelity event photography metered by the shutter. Attendees or hosts pay 0.01 USDC per high-res download or social unlock via HTS transfer. Eliminate bulk package friction; users pay only for the memories they keep. Facilitators settle transfers instantly, allowing photographers to monetize individual 'hero shots' in real-time as they hit the chain. Discipline: Photography (event photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional event photography relies on expensive flat fees or clumsy watermarked galleries. By turning every 'save' into a 0.01 USDC micro-transaction, you convert passive viewers into active buyers, removing the psychological barrier of $20 digital downloads. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Flashback" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-privy-photo-feedback-11-x402 Title: CRITIQ · x402 Theme: Photography (photography) · photo critique Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Proof-of-Eye is the hyper-critique layer for creators. Stop looking for 'likes' and pay for professional scrutiny. Each HTS transfer signed request (0.01 USDC) triggers a high-fidelity agentic critique or a ranked peer review. Every critique is settled on Hedera, turning artistic growth into a metered utility. No subscriptions, just pure, paid-per-pixel improvement. Why Hedera: Shifts from 'free/sponsored' to 'value-per-call.' Micropayments prevent spam and ensure the feedback provider (human or agent) is compensated for the specialized compute or time required for high-quality critique. Market: TAM $3.2B — The global online photo editing and professional development market. | SAM $450M — The digital creator economy and photography education sector moving toward micro-consultations. | SOM $12M — The Base-native cohort of mobile photographers using HashPack for instant, low-friction creative feedback. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CRITIQ" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Proof-of-Eye is the hyper-critique layer for creators. Stop looking for 'likes' and pay for professional scrutiny. Each HTS transfer signed request (0.01 USDC) triggers a high-fidelity agentic critique or a ranked peer review. Every critique is settled on Hedera, turning artistic growth into a metered utility. No subscriptions, just pure, paid-per-pixel improvement. Discipline: Photography (photo critique). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from 'free/sponsored' to 'value-per-call.' Micropayments prevent spam and ensure the feedback provider (human or agent) is compensated for the specialized compute or time required for high-quality critique. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CRITIQ" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-gasless-photo-auctions-12-x402 Title: Snapshot Bid · x402 Theme: Photography (photography) · photo auctions Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Bid with precision. Every bid placement or private gallery 'peek' is a 0.01 USDC micro-transaction. Auctions settle instantly on-chain, eliminating bot spam while allowing high-frequency price discovery for digital photography. Pay only for the auctions you actively engage in. Why Hedera: By shifting from 'gasless' to 'metered,' we replace the cost of gas with the cost of intent. This filters out noise and creates a high-signal environment for collectors where every action—from viewing a high-res proof to outbidding a rival—is a micro-payment that secures the network and compensates the creator. Market: TAM $4.2B — The global online art auction market transitioning to instant, micro-settlement rails. | SAM $850M — High-end digital art and photography collectors prioritizing low-latency bidding. | SOM $12M — Hedera testnet early adopters and mobile-first photography enthusiasts using embedded wallets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Snapshot Bid" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Bid with precision. Every bid placement or private gallery 'peek' is a 0.01 USDC micro-transaction. Auctions settle instantly on-chain, eliminating bot spam while allowing high-frequency price discovery for digital photography. Pay only for the auctions you actively engage in. Discipline: Photography (photo auctions). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'gasless' to 'metered,' we replace the cost of gas with the cost of intent. This filters out noise and creates a high-signal environment for collectors where every action—from viewing a high-res proof to outbidding a rival—is a micro-payment that secures the network and compensates the creator. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Snapshot Bid" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-chain-stamped-edits-13-x402 Title: PROOF-SHEET · x402 Theme: Photography (photography) · edit provenance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metadata-in-frame camera plugin that seals every slider adjustment, crop, and color grade as a cryptographic proof. $0.01 USDC per 'Save' triggers an HTS transfer transfer, anchoring the edit hash to Base and returning a permanent provenance receipt. Instant authenticity for professional workflows without gas friction. Why Hedera: By switching to a pay-per-seal model, the app treats 'truth' as a micro-service. Pros pay for the security of verifiable edits, while the facilitator handles the Base gas, making the transaction feel like a native app feature rather than a blockchain interaction. Market: TAM $2.8B — The global digital asset management and authentication market within the creator economy. | SAM $110M — Professional photographers and digital journalists requiring verifiable content authenticity. | SOM $12M — Early adopters in newsrooms and commercial studios utilizing Base-native provenance tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PROOF-SHEET" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metadata-in-frame camera plugin that seals every slider adjustment, crop, and color grade as a cryptographic proof. $0.01 USDC per 'Save' triggers an HTS transfer transfer, anchoring the edit hash to Base and returning a permanent provenance receipt. Instant authenticity for professional workflows without gas friction. Discipline: Photography (edit provenance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By switching to a pay-per-seal model, the app treats 'truth' as a micro-service. Pros pay for the security of verifiable edits, while the facilitator handles the Base gas, making the transaction feel like a native app feature rather than a blockchain interaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PROOF-SHEET" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-sponsored-tx-model-releases-14-x402 Title: PROTOCOL RELEASE · x402 Theme: Photography (photography) · legal docs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Instant, legally-binding model releases settled via USDC micropayments. Photographers pay 0.01 USDC to mint a verified consent record, while talent receives micro-royalties or instant 'kill-switch' rights. Every signature is a secure, metered event on Hedera. Why Hedera: Shifts the model from a 'free doc' to a 'metered asset.' By using x402, the legal validity of the release is tied to a verifiable payment stream, eliminating disputes over 'consideration' in contract law. Market: TAM $4.2B — Global digital rights management and photography licensing market. | SAM $850M — Professional photographers and digital content creators requiring high-volume compliance. | SOM $12M — Web3-native commercial photographers and AI-gen model trainers using Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PROTOCOL RELEASE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Instant, legally-binding model releases settled via USDC micropayments. Photographers pay 0.01 USDC to mint a verified consent record, while talent receives micro-royalties or instant 'kill-switch' rights. Every signature is a secure, metered event on Hedera. Discipline: Photography (legal docs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from a 'free doc' to a 'metered asset.' By using x402, the legal validity of the release is tied to a verifiable payment stream, eliminating disputes over 'consideration' in contract law. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PROTOCOL RELEASE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-onchain-photo-licensing-15-x402 Title: SnapSeal · x402 Theme: Photography (photography) · rights clearing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Clear usage rights for high-res imagery instantly. Each x402 signature triggers a 0.01 USDC micro-license, allowing publications or AI training sets to legally ingest single assets without legal overhead or subscription bloat. Paperwork is replaced by a signed HTS transfer transfer. Why Hedera: Moving from 'negotiation' to 'metered clearing' removes friction. By making the payment the legal 'unlock' event, photographers get paid per impression or per download, turning their portfolio into a high-velocity liquidity pool. Market: TAM $12B — Global digital rights management and asset licensing industry. | SAM $1.8B — The stock photography and rights-clearing market transitioning to real-time, programmatic licensing. | SOM $45M — On-demand licensing for indie digital publications and GenAI model fine-tuning sets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SnapSeal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Clear usage rights for high-res imagery instantly. Each x402 signature triggers a 0.01 USDC micro-license, allowing publications or AI training sets to legally ingest single assets without legal overhead or subscription bloat. Paperwork is replaced by a signed HTS transfer transfer. Discipline: Photography (rights clearing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'negotiation' to 'metered clearing' removes friction. By making the payment the legal 'unlock' event, photographers get paid per impression or per download, turning their portfolio into a high-velocity liquidity pool. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SnapSeal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-gasless-watermark-tags-16-x402 Title: ProofShot · x402 Theme: Photography (photography) · copyright protection Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — cryptographically seal high-res images with a traceable owner-tag. Payment triggers a pixel-level embedding process and logs the hash to Base. Creators can batch-protect libraries or expose a paid API for platforms to verify image provenance instantly. No subscriptions, just sub-cent protection per shutter click. Why Hedera: x402 transforms copyright from a legal hurdle into a metered service. By pricing each watermark at $0.01, it enables a frictionless 'Pay-per-Seal' model that integrates directly into camera hardware or CMS plugins via the embedded wallet. Market: TAM $4.2B — The global stock photography and image licensing industry transitioning to verifiable media. | SAM $280M — The digital rights management (DRM) and image security software market. | SOM $12M — Independent photographers and NFT creators requiring per-image provenance on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ProofShot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — cryptographically seal high-res images with a traceable owner-tag. Payment triggers a pixel-level embedding process and logs the hash to Base. Creators can batch-protect libraries or expose a paid API for platforms to verify image provenance instantly. No subscriptions, just sub-cent protection per shutter click. Discipline: Photography (copyright protection). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: x402 transforms copyright from a legal hurdle into a metered service. By pricing each watermark at $0.01, it enables a frictionless 'Pay-per-Seal' model that integrates directly into camera hardware or CMS plugins via the embedded wallet. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ProofShot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-privy-photo-assets-17-x402 Title: ShutterProof · x402 Theme: Photography (photography) · asset management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An automated vault for high-res creative assets where every hi-res download, metadata scrub, or collaborative 'pick' is a discrete 0.01 USDC event. Stop subsidizing bulk storage; move to a micro-metered access model where creators are paid instantly when scouts or clients view their work. Integrated HTS transfer permits allow for 'pay-as-you-look' galleries with sub-cent settlement. Why Hedera: Shifts the value from 'storage' (which is a race to zero) to 'access granularity.' By making every high-fidelity interaction a transaction, it eliminates the need for monthly subscriptions and ensures photographers are compensated for the exact volume of interest their work generates. Market: TAM $12B — Global digital asset management (DAM) market transitioning to granular, programmable licensing. | SAM $420M — Freelance commercial photographers and agency art buyers shifting to onchain proof-of-usage. | SOM $15M — Early adopters in the Base ecosystem using HashPack for frictionless creative handoffs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ShutterProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An automated vault for high-res creative assets where every hi-res download, metadata scrub, or collaborative 'pick' is a discrete 0.01 USDC event. Stop subsidizing bulk storage; move to a micro-metered access model where creators are paid instantly when scouts or clients view their work. Integrated HTS transfer permits allow for 'pay-as-you-look' galleries with sub-cent settlement. Discipline: Photography (asset management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the value from 'storage' (which is a race to zero) to 'access granularity.' By making every high-fidelity interaction a transaction, it eliminates the need for monthly subscriptions and ensures photographers are compensated for the exact volume of interest their work generates. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ShutterProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-sponsored-tx-photo-tips-18-x402 Title: SHUTTER · x402 Theme: Photography (photography) · content monetization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A 'Pay-per-View' high-res photography gallery where every image is an x402-gated asset. Instead of clumsy subscriptions or ads, users pay 0.01 USDC to instantly decrypt and view the full-quality RAW file. Creators receive instant, streaming settlement for every unique look, while the the embedded wallet-signed signature ensures the 'tip' is the actual access key. Gas is abstracted, making the payment as seamless as a shutter click. Why Hedera: Traditional tipping is an after-thought; x402 makes the value exchange the primary interaction. By transforming tips into micro-payments for access, we eliminate the friction of 'charity' and turn photography into a metered utility. Market: TAM $4.2B — The global stock photography and creator-support market transitioning to agent-accessible asset APIs. | SAM $850M — Onchain photography enthusiasts and digital collectors using Base. | SOM $12M — Professional photographers migrating from Patreon/Instagram to direct-monetization micro-galleries. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SHUTTER" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A 'Pay-per-View' high-res photography gallery where every image is an x402-gated asset. Instead of clumsy subscriptions or ads, users pay 0.01 USDC to instantly decrypt and view the full-quality RAW file. Creators receive instant, streaming settlement for every unique look, while the the embedded wallet-signed signature ensures the 'tip' is the actual access key. Gas is abstracted, making the payment as seamless as a shutter click. Discipline: Photography (content monetization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional tipping is an after-thought; x402 makes the value exchange the primary interaction. By transforming tips into micro-payments for access, we eliminate the friction of 'charity' and turn photography into a metered utility. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SHUTTER" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-chain-verified-exif-19-x402 Title: TrueLens · x402 Theme: Photography (photography) · photo metadata Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Photographers and journalists pay 0.01 USDC to seal a photo's origin, timestamp, and GPS coordinates into an immutable cryptographic proof. Every metadata 'anchor' generates a permanent Base transaction hash, providing instant proof of authenticity for newsrooms and collectors without forcing users to own ETH. Buy truth one frame at a time. Why Hedera: By turning metadata verification into a per-usage fee, we solve the 'junk data' problem. The x402 model makes 'Proof of Reality' affordable for smartphone users while ensuring the storage cost is covered by the creator at the moment of capture. Market: TAM $2.8B — The global digital asset verification and anti-deepfake market. | SAM $240M — Professional photojournalists, stock photographers, and insurance adjusters requiring verified provenance. | SOM $12M — Independent mobile journalists and citizen reporters in high-conflict zones using Base for rapid verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueLens" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Photographers and journalists pay 0.01 USDC to seal a photo's origin, timestamp, and GPS coordinates into an immutable cryptographic proof. Every metadata 'anchor' generates a permanent Base transaction hash, providing instant proof of authenticity for newsrooms and collectors without forcing users to own ETH. Buy truth one frame at a time. Discipline: Photography (photo metadata). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning metadata verification into a per-usage fee, we solve the 'junk data' problem. The x402 model makes 'Proof of Reality' affordable for smartphone users while ensuring the storage cost is covered by the creator at the moment of capture. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueLens" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-gasless-photo-badges-20-x402 Title: Proofshot · x402 Theme: Photography (photography) · community rewards Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-bounty protocol for photographers. Creators post high-resolution proofs of work; community members pay 0.01 USDC to 'Verify & Badge' an achievement. Each payment triggers a signed HTS transfer transfer that mints a soulbound achievement badge to the photographer and distributes a micro-royalty to the verifier, turning community recognition into a liquid, high-velocity reputation system. Why Hedera: By replacing 'gasless/free' with 'micro-paid verification,' the badge gains economic weight. The 0.01 USDC fee acts as a spam filter and a direct incentive for community curation, moving from passive 'claiming' to active 'investing' in a photographer's social graph. Market: TAM $45B — The global digital collectibles and professional certification market transitioning to agent-verified credentials. | SAM $2.1B — The emerging 'Proof of Passion' market where fans sponsor creator milestones via micropayments. | SOM $18M — Initial capture of street photography communities and high-utility Discord/Telegram alpha groups. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proofshot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-bounty protocol for photographers. Creators post high-resolution proofs of work; community members pay 0.01 USDC to 'Verify & Badge' an achievement. Each payment triggers a signed HTS transfer transfer that mints a soulbound achievement badge to the photographer and distributes a micro-royalty to the verifier, turning community recognition into a liquid, high-velocity reputation system. Discipline: Photography (community rewards). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing 'gasless/free' with 'micro-paid verification,' the badge gains economic weight. The 0.01 USDC fee acts as a spam filter and a direct incentive for community curation, moving from passive 'claiming' to active 'investing' in a photographer's social graph. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proofshot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-onchain-lens-reviews-21-x402 Title: GlassCheck · x402 Theme: Photography (photography) · equipment reviews Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Access high-fidelity, crowdsourced lens performance data and raw RAW-file sample banks with x402-metered unlocks. Instead of monthly subs or ad-bloat, pay 0.01 USDC per spec-sheet or sample download. Reviewers earn direct micropayments for every unique 'view' of their lens tests, creating a self-sustaining onchain laboratory for optics. Why Hedera: Moving from free reviews to paid data-chunks prevents sybil spam and rewards high-quality testers. The x402 model turns a static blog into a high-utility API of professional optics data where every data point is an asset. Market: TAM $2.5B — Global photography equipment marketing and professional review industry. | SAM $120M — Professional photographers and gear-review enthusiasts looking for peer-verified raw data. | SOM $4M — Early-adopter tech reviewers and gear-heads on Hedera seeking verified onchain equipment verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GlassCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Access high-fidelity, crowdsourced lens performance data and raw RAW-file sample banks with x402-metered unlocks. Instead of monthly subs or ad-bloat, pay 0.01 USDC per spec-sheet or sample download. Reviewers earn direct micropayments for every unique 'view' of their lens tests, creating a self-sustaining onchain laboratory for optics. Discipline: Photography (equipment reviews). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from free reviews to paid data-chunks prevents sybil spam and rewards high-quality testers. The x402 model turns a static blog into a high-utility API of professional optics data where every data point is an asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GlassCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-privy-photo-grants-22-x402 Title: FOCAL · x402 Theme: Photography (photography) · creative funding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized 'bounty' board for creative vision. Photographers post 'Work-in-Progress' thumbnails. Supporters unlock the high-res RAW files and project journals for 0.01 USDC. Each micropayment acts as a granular 'vote-grant,' streaming funds directly to the creator's Magic Link email sign-in. No gas, just friction-less creative conviction. Why Hedera: By shifting from large lump-sum grants to hyper-granular 0.01 USDC unlocks, we solve the 'passive fan' problem. x402 allows for high-velocity funding where 1,000 micro-interactions provide more sustainable signal and capital than one bureaucratic grant. Market: TAM $15B — The global creative grant and crowdfunding market shifting toward micro-patronage. | SAM $1.2B — The total addressable market for online photography patronage and stock licensing. | SOM $45M — The segment of tech-forward creators and DAOs utilizing onchain distribution methods. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FOCAL" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized 'bounty' board for creative vision. Photographers post 'Work-in-Progress' thumbnails. Supporters unlock the high-res RAW files and project journals for 0.01 USDC. Each micropayment acts as a granular 'vote-grant,' streaming funds directly to the creator's Magic Link email sign-in. No gas, just friction-less creative conviction. Discipline: Photography (creative funding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from large lump-sum grants to hyper-granular 0.01 USDC unlocks, we solve the 'passive fan' problem. x402 allows for high-velocity funding where 1,000 micro-interactions provide more sustainable signal and capital than one bureaucratic grant. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FOCAL" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-chain-linked-portfolios-23-x402 Title: LensLink · x402 Theme: Photography (photography) · portfolio management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view portfolio engine where creators earn 0.01 USDC instantly for every high-res asset unlock. No subscriptions; fans and recruiters pay exactly for what they see. Each 'view' triggers an HTS transfer signed transfer that permanently links the viewer's wallet to the asset's metadata on Hedera. Set your price per click or per gallery, eliminating the need for gated landing pages or ads. Why Hedera: Portfolios currently suffer from 'ghost traffic' where views have zero value. Turning every asset click into a micro-transaction ensures photographers are paid for their digital reach while preventing bulk scraping. Market: TAM $4.2B — The global photography and freelance portfolio market shifting toward micro-monetized digital goods. | SAM $480M — The addressable market for decentralized portfolio hosting and professional creative asset delivery. | SOM $12M — Onchain photographers and digital artists on Hedera seeking direct-to-wallet monetization without platform fees. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LensLink" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view portfolio engine where creators earn 0.01 USDC instantly for every high-res asset unlock. No subscriptions; fans and recruiters pay exactly for what they see. Each 'view' triggers an HTS transfer signed transfer that permanently links the viewer's wallet to the asset's metadata on Hedera. Set your price per click or per gallery, eliminating the need for gated landing pages or ads. Discipline: Photography (portfolio management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Portfolios currently suffer from 'ghost traffic' where views have zero value. Turning every asset click into a micro-transaction ensures photographers are paid for their digital reach while preventing bulk scraping. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LensLink" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-sponsored-tx-photo-collages-24-x402 Title: Fragment · x402 Theme: Photography (photography) · creative assembly Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Transform image assets into immutable creative assemblies. Every 'Add Layer' or 'Finalize Composition' action triggers a 0.01 USDC x402 stream to the artist-curators and protocol. No more free-riding on curation; users pay per precision edit, ensuring every pixel is backed by a micropayment settlement. Why Hedera: Transitioning from 'Free/Sponsored' to 'Metered Creative Assembly' changes the user psychology from passive consumption to active curation. Using x402 allows for granular sub-cent monetization of intellectual property (stickers, backgrounds, textures) within the collage workflow. Market: TAM $8.5B — Global digital photography and collage-making market moving toward micro-ownership and agentic creator economies. | SAM $1.2B — Projected spend from mobile creators using premium onchain asset libraries and modular design tools. | SOM $15M — Targeting the initial wave of Base-native creators and 'Farcaster Frames' power users who desire unique, provable visual assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Fragment" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Transform image assets into immutable creative assemblies. Every 'Add Layer' or 'Finalize Composition' action triggers a 0.01 USDC x402 stream to the artist-curators and protocol. No more free-riding on curation; users pay per precision edit, ensuring every pixel is backed by a micropayment settlement. Discipline: Photography (creative assembly). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from 'Free/Sponsored' to 'Metered Creative Assembly' changes the user psychology from passive consumption to active curation. Using x402 allows for granular sub-cent monetization of intellectual property (stickers, backgrounds, textures) within the collage workflow. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Fragment" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-trueshot-ledger-0-x402 Title: TrueShot · x402 Theme: Photography (photography) · photo authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 per provenance seal. TrueShot settles an immutable cryptographic leaf on Hedera for every shutter press. Stop unauthorized scraping—make every re-use a micropayment opportunity. Platforms pay to verify; you get paid when they ask. Why Hedera: By moving provenance from a static 'check' to a per-call verification, we turn image metadata into a revenue-generating asset. Every time an aggregator or news site validates the photo's authenticity via the API, the photographer earns. Market: TAM $18B — Global digital image authentication and deepfake detection sector for AI-generated content verification. | SAM $4.2B — The licensing and digital rights management (DRM) software market. | SOM $125M — Professional photojournalists and commercial studios requiring real-time sub-cent verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueShot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 per provenance seal. TrueShot settles an immutable cryptographic leaf on Hedera for every shutter press. Stop unauthorized scraping—make every re-use a micropayment opportunity. Platforms pay to verify; you get paid when they ask. Discipline: Photography (photo authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving provenance from a static 'check' to a per-call verification, we turn image metadata into a revenue-generating asset. Every time an aggregator or news site validates the photo's authenticity via the API, the photographer earns. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueShot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-edittrace-chain-1-x402 Title: Provenance · x402 Theme: Photography (photography) · photo editing history Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A non-destructive editing ledger where every adjustment layer is captured on-chain. Producers pay 0.01 USDC to 'Rollback' or 'Fork' an edit state, turning creative history into a verifiable, liquid asset. Pay-per-reversion ensures accountability while rewarding creators who share their workflow 'recipes' for others to learn from. Why Hedera: By monetizing the granular steps of the editing process (the 'history' rather than just the 'result'), we shift the value from a static JPEG to the intellectual property of the process itself. x402 handles the micro-metering required for high-frequency undo/redo or state-branching actions. Market: TAM $4.2B — The global creative software economy moving toward transparent, AI-interactive workflows. | SAM $450M — The digital asset management and professional photo editing tool market for creators. | SOM $12M — High-stakes commercial photography, forensics, and AI-training datasets requiring verifiable human edit-trails. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Provenance" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A non-destructive editing ledger where every adjustment layer is captured on-chain. Producers pay 0.01 USDC to 'Rollback' or 'Fork' an edit state, turning creative history into a verifiable, liquid asset. Pay-per-reversion ensures accountability while rewarding creators who share their workflow 'recipes' for others to learn from. Discipline: Photography (photo editing history). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By monetizing the granular steps of the editing process (the 'history' rather than just the 'result'), we shift the value from a static JPEG to the intellectual property of the process itself. x402 handles the micro-metering required for high-frequency undo/redo or state-branching actions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Provenance" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-proprint-certify-2-x402 Title: Aura Proof · x402 Theme: Photography (photography) · fine art prints Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Each high-resolution print scan or digital 'Proof of Authenticity' is metered at $0.01 per provenance check via HTS transfer. Photographers pay per signature; collectors pay per verification. No subscriptions, just granular, immutable lineage for every edition. Why Hedera: Fine art requires trust. By making provenance inquiries and certifications individual micropayments, we eliminate the friction of high SaaS fees for emerging artists while turning every 'view' of a digital twin into a revenue micro-event. Market: TAM $4.2B — Global art authentication and provenance tracking market. | SAM $800M — The digital-physical hybrid art market and high-end editioned photography. | SOM $12M — Independent fine art photographers utilizing Base for on-chain proof-of-work. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Aura Proof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Each high-resolution print scan or digital 'Proof of Authenticity' is metered at $0.01 per provenance check via HTS transfer. Photographers pay per signature; collectors pay per verification. No subscriptions, just granular, immutable lineage for every edition. Discipline: Photography (fine art prints). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Fine art requires trust. By making provenance inquiries and certifications individual micropayments, we eliminate the friction of high SaaS fees for emerging artists while turning every 'view' of a digital twin into a revenue micro-event. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Aura Proof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-snapevent-auth-3-x402 Title: FlashPass · x402 Theme: Photography (photography) · event photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-view access for high-res event photography. Instead of bulky watermark-removal packages, users sign a $0.01 HTS transfer authorization to instantly unlock and high-speed download original files directly from the gallery. Micropayments handle the licensing per-click, settling fees to the photographer in real-time. Why Hedera: Moving from a lump-sum contract to a consumption-based 'unlock' model increases conversion at high-volume events (marathons, festivals) where attendees only want specific shots. Market: TAM $4.2B — Global event photography and digital asset licensing market. | SAM $450M — Event photographers and hobbyists using digital marketplaces. | SOM $12M — Web3-integrated event photo galleries and festival activations on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FlashPass" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-view access for high-res event photography. Instead of bulky watermark-removal packages, users sign a $0.01 HTS transfer authorization to instantly unlock and high-speed download original files directly from the gallery. Micropayments handle the licensing per-click, settling fees to the photographer in real-time. Discipline: Photography (event photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a lump-sum contract to a consumption-based 'unlock' model increases conversion at high-volume events (marathons, festivals) where attendees only want specific shots. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FlashPass" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-newsframe-provenance-4-x402 Title: ProofPoint · x402 Theme: Photography (photography) · photojournalism Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hardware-to-onchain bridge for war zone reporters. Pay 0.01 USDC to instant-anchor a photo's metadata and location to Base, generating a tamper-proof provenance hash. Readers or news desks pay 0.01 USDC to decrypt and verify the original high-res RAW file, ensuring absolute authenticity in a deepfake era. Why Hedera: Photojournalism is suffering from a trust crisis. By making trust-verification a granular micropayment, we turn each 'check' into a revenue stream for the reporter and each 'anchor' into a high-integrity archival act. Market: TAM $2.8B — The global news trust and digital forensic market. | SAM $450M — The addressable market for digital asset verification and professional photo licensing. | SOM $12M — Independent frontline reporters and agency field units using per-image micro-storage. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ProofPoint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hardware-to-onchain bridge for war zone reporters. Pay 0.01 USDC to instant-anchor a photo's metadata and location to Base, generating a tamper-proof provenance hash. Readers or news desks pay 0.01 USDC to decrypt and verify the original high-res RAW file, ensuring absolute authenticity in a deepfake era. Discipline: Photography (photojournalism). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Photojournalism is suffering from a trust crisis. By making trust-verification a granular micropayment, we turn each 'check' into a revenue stream for the reporter and each 'anchor' into a high-integrity archival act. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ProofPoint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-stocksnap-rights-5-x402 Title: SnapFlow · x402 Theme: Photography (photography) · stock photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-view high-res licensing. Stop buying $50 bundles you don't need. StockSnap meters every raw asset fetch. Users sign a 0.01 USDC transfer via the embedded wallet to instantly unlock a 4K, watermark-free download. For devs and designers, it's a headless API where your script pays for every image it scrapes into a layout. Every transaction is a persistent license record on Hedera. Why Hedera: Traditional stock photography suffers from high friction (subscriptions) or high cost (single-image pricing). x402 enables 'Nano-Licensing' where the cost of a single web-tier image is negligible, encouraging high-volume usage by both humans and AI image-processing agents. Market: TAM $4.8B — The global stock image and video market shifting toward granular, programmatic consumption. | SAM $450M — The digital content licensing market specifically for independent creators and agile marketing agencies. | SOM $12M — Early-adoption volume from automated UI builders and AI-agent workflows requiring licensed assets on-demand. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SnapFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-view high-res licensing. Stop buying $50 bundles you don't need. StockSnap meters every raw asset fetch. Users sign a 0.01 USDC transfer via the embedded wallet to instantly unlock a 4K, watermark-free download. For devs and designers, it's a headless API where your script pays for every image it scrapes into a layout. Every transaction is a persistent license record on Hedera. Discipline: Photography (stock photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional stock photography suffers from high friction (subscriptions) or high cost (single-image pricing). x402 enables 'Nano-Licensing' where the cost of a single web-tier image is negligible, encouraging high-volume usage by both humans and AI image-processing agents. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SnapFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-lensstory-archive-6-x402 Title: LensStory · x402 Theme: Photography (photography) · photography archives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular licensing protocol for photojournalists and archivists. Instead of bulk licenses, users pay 0.01 USDC to unlock high-resolution metadata, provenance history, or full-sized RAW downloads for a single asset. Every view is a micro-settlement directly to the photographer's vault. Why Hedera: Traditional stock photo sites have high friction and gatekeeping. LensStory turns every archival image into a metered API endpoint, allowing AI training sets, researchers, and bloggers to pay for exactly what they consume via HTS transfer. Market: TAM $4.2B — The global digital asset management and metadata preservation market. | SAM $450M — The digital archival and stock photography licensing market moving on-chain. | SOM $12M — Independent investigative photojournalists and historical societies requiring verifiable provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LensStory" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular licensing protocol for photojournalists and archivists. Instead of bulk licenses, users pay 0.01 USDC to unlock high-resolution metadata, provenance history, or full-sized RAW downloads for a single asset. Every view is a micro-settlement directly to the photographer's vault. Discipline: Photography (photography archives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional stock photo sites have high friction and gatekeeping. LensStory turns every archival image into a metered API endpoint, allowing AI training sets, researchers, and bloggers to pay for exactly what they consume via HTS transfer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LensStory" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-collaboshot-chain-7-x402 Title: RAWSET · x402 Theme: Photography (photography) · collaborative shoots Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-frame digital darkroom. Collaborative shoots where every shutter press or edit is a signed micropayment. Instead of messy 'revenue shares,' photographers and editors pay a fractional USDC fee to 'stack' their layer onto a master RAW file. Payment acts as the cryptographic proof of contribution, instantly distributing micro-royalties to the previous layer's creator the moment the next editor unlocks the file. Fees settle via HTS transfer to ensure gasless participation for the whole crew. Why Hedera: By shifting from NFTs (fixed assets) to x402 (metered actions), the app handles the complexity of 'who did what' via the payment stream itself. A paid 'unlock' is the legal and technical receipt of collaboration. Market: TAM $4.2B — The global digital asset management and collaborative creative software market. | SAM $180M — The gig economy for freelance photo editors, retouchers, and digital artists moving to Base. | SOM $12M — High-velocity fashion and commercial studios utilizing decentralized post-production pipelines. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RAWSET" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-frame digital darkroom. Collaborative shoots where every shutter press or edit is a signed micropayment. Instead of messy 'revenue shares,' photographers and editors pay a fractional USDC fee to 'stack' their layer onto a master RAW file. Payment acts as the cryptographic proof of contribution, instantly distributing micro-royalties to the previous layer's creator the moment the next editor unlocks the file. Fees settle via HTS transfer to ensure gasless participation for the whole crew. Discipline: Photography (collaborative shoots). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from NFTs (fixed assets) to x402 (metered actions), the app handles the complexity of 'who did what' via the payment stream itself. A paid 'unlock' is the legal and technical receipt of collaboration. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RAWSET" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-vintagevibe-mint-8-x402 Title: GRAIN · x402 Theme: Photography (photography) · vintage photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity photochemical preservation layer. Pay 0.01 USDC to generate a cryptographic 'Provenance Seal' for any scanned vintage negative. Each payment covers the storage of metadata and the signature of a Base transaction hash, anchoring the physical heritage to a digital identity without per-month subscriptions. Perfect for vintage estates and analog archivists who only pay for what they scan. Why Hedera: Traditional NFT minting is too expensive for bulk family archives. x402 allows for granular, 'per-frame' payment architecture, making preservation affordable and professional for high-volume analog digitizers. Market: TAM $6.4B — The global digital asset management and historical preservation market. | SAM $850M — The addressable market for the 35mm film revival and professional analog scanning services. | SOM $12M — The immediate niche of high-end film labs and vintage camera aficionados requiring immutable proof of authenticity. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GRAIN" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity photochemical preservation layer. Pay 0.01 USDC to generate a cryptographic 'Provenance Seal' for any scanned vintage negative. Each payment covers the storage of metadata and the signature of a Base transaction hash, anchoring the physical heritage to a digital identity without per-month subscriptions. Perfect for vintage estates and analog archivists who only pay for what they scan. Discipline: Photography (vintage photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT minting is too expensive for bulk family archives. x402 allows for granular, 'per-frame' payment architecture, making preservation affordable and professional for high-volume analog digitizers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GRAIN" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-framechain-gallery-9-x402 Title: Glance · x402 Theme: Photography (photography) · digital galleries Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity digital gallery where exhibition is ephemeral and exploration is metered. Users pay 0.01 USDC to 'step' into a high-res gallery space or unlock a photographer's private vault. Each HTS transfer signature mints a temporary viewing session, ensuring artists are paid for every single gaze without subscription friction. Why Hedera: Traditional NFT galleries suffer from 'looker vs. buyer' disparity; x402 levels the field by charging for the experience of high-res viewing. It turns digital galleries into virtual museums where the entry fee is automated and microscopic. Market: TAM $4.2B — The global digital art and collectibles market transitioning to metered access. | SAM $120M — Professional photographers and digital art collectors adopting pay-per-view gallery models. | SOM $8.5M — Niche crypto-native photography communities and digital curators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Glance" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity digital gallery where exhibition is ephemeral and exploration is metered. Users pay 0.01 USDC to 'step' into a high-res gallery space or unlock a photographer's private vault. Each HTS transfer signature mints a temporary viewing session, ensuring artists are paid for every single gaze without subscription friction. Discipline: Photography (digital galleries). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT galleries suffer from 'looker vs. buyer' disparity; x402 levels the field by charging for the experience of high-res viewing. It turns digital galleries into virtual museums where the entry fee is automated and microscopic. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Glance" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-focusproof-vault-10-x402 Title: FocusProof · x402 Theme: Photography (photography) · photo legal evidence Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Secure, tamper-proof forensic verification. Every shutter press triggers a signed HTS transfer transfer to timestamp the image hash and GPS coordinates onto Base. Legal admissibility is bought at the millisecond of capture, ensuring a chain of custody that agents and insurers can verify for pennies. Pay per proof, not per subscription. Why Hedera: Traditional forensic software is gatekept by high enterprise fees. x402 allows gig workers, accident victims, and citizen journalists to access 'legal-grade' metadata anchoring on a per-use basis, creating a high-volume, low-friction trust layer. Market: TAM $14.2B — Global digital forensics and incident response (DFIR) market. | SAM $450M — Independent legal consultants, insurance adjusters, and gig-economy delivery drivers (e.g., DoorDash property damage disputes). | SOM $12M — Web3-native insurance protocols and on-chain dispute resolution DAOs requiring verifiable physical evidence. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FocusProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Secure, tamper-proof forensic verification. Every shutter press triggers a signed HTS transfer transfer to timestamp the image hash and GPS coordinates onto Base. Legal admissibility is bought at the millisecond of capture, ensuring a chain of custody that agents and insurers can verify for pennies. Pay per proof, not per subscription. Discipline: Photography (photo legal evidence). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional forensic software is gatekept by high enterprise fees. x402 allows gig workers, accident victims, and citizen journalists to access 'legal-grade' metadata anchoring on a per-use basis, creating a high-volume, low-friction trust layer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FocusProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-macromint-origins-11-x402 Title: DeepFocus · x402 Theme: Photography (photography) · macro photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized inspection lens for high-resolution macro metadata. Pay 0.01 USDC to unlock the raw cryptographic provenance of a macro shot, verifying lens data and focal depth to distinguish genuine optical captures from AI-generated imagery. Each micropayment flows directly to the photographer's the embedded wallet-managed vault, turning every 'view' into a granular licensing event. Why Hedera: Macro photography relies on technical authenticity. By shifting from high-friction NFT mints to sub-cent 'view-to-verify' payments, the app creates a high-velocity revenue stream for creators while securing the integrity of the art. Market: TAM $4.2B — The global digital stock photography and image authentication market. | SAM $140M — Professional nature photographers and digital art collectors on Hedera. | SOM $8M — Initial cohort of macro-enthusiasts using Base-integrated social layers like Farcaster. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DeepFocus" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized inspection lens for high-resolution macro metadata. Pay 0.01 USDC to unlock the raw cryptographic provenance of a macro shot, verifying lens data and focal depth to distinguish genuine optical captures from AI-generated imagery. Each micropayment flows directly to the photographer's the embedded wallet-managed vault, turning every 'view' into a granular licensing event. Discipline: Photography (macro photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Macro photography relies on technical authenticity. By shifting from high-friction NFT mints to sub-cent 'view-to-verify' payments, the app creates a high-velocity revenue stream for creators while securing the integrity of the art. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DeepFocus" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-timelapse-token-12-x402 Title: CHRONOS · x402 Theme: Photography (photography) · time-lapse photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Capture and commit high-resolution frames to a verifiable timeline where every shutter trigger is a micro-transaction. Eliminates AI-generated fakes by requiring a signed 'proof-of-presence' payment for every image in the sequence. Creators monetize high-value time-lapses (construction, astronomical events, urban shifts) by charging per-frame for commercial licensing or high-res downloads, settled instantly via HTS transfer. Why Hedera: Shifts the app from simple storage to an active, metered proof-of-work/proof-of-shutter system. By making the payment the primitive for the commit, the blockchain acts as a ledger of physical reality, making 'TimeLapse' a trust-protocol for visual data. Market: TAM $3.8B — Global digital image licensing and commercial surveillance verification markets. | SAM $420M — Professional stock footage market and decentralized physical infrastructure (DePIN) verification. | SOM $12M — Independent nature photographers and construction monitoring firms seeking immutable progress logs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CHRONOS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Capture and commit high-resolution frames to a verifiable timeline where every shutter trigger is a micro-transaction. Eliminates AI-generated fakes by requiring a signed 'proof-of-presence' payment for every image in the sequence. Creators monetize high-value time-lapses (construction, astronomical events, urban shifts) by charging per-frame for commercial licensing or high-res downloads, settled instantly via HTS transfer. Discipline: Photography (time-lapse photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the app from simple storage to an active, metered proof-of-work/proof-of-shutter system. By making the payment the primitive for the commit, the blockchain acts as a ledger of physical reality, making 'TimeLapse' a trust-protocol for visual data. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CHRONOS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-portraitclaim-chain-13-x402 Title: Prohibit · x402 Theme: Photography (photography) · portrait photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-license shutter system where high-res portrait exports and commercial usage rights are metered at the grain of 0.01 USDC. Photographers no longer chase invoices; clients sign HTS transfer permits to unlock water-mark free assets. Each transaction triggers a Base settlement, instantly embedding the licensee's wallet address into the portrait's on-chain provenance. Pay for the rights you use, one frame at a time. Why Hedera: Moving from monolithic NFT minting to x402-metered licensing allows for 'micro-rights'—paying fractionally for social media vs. print usage, or individual headshot unlocks. It eliminates friction for the client while ensuring the photographer is paid per asset requested. Market: TAM $4.5B — The global professional photography and digital asset management market transitioning to automated, agent-compatible micro-licensing. | SAM $850M — The addressable market for digital licensing, micro-stock photography, and influencer brand-deal asset management. | SOM $12M — Independent portrait photographers and creative agencies adopting sub-dollar, instant-settlement licensing models. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Prohibit" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-license shutter system where high-res portrait exports and commercial usage rights are metered at the grain of 0.01 USDC. Photographers no longer chase invoices; clients sign HTS transfer permits to unlock water-mark free assets. Each transaction triggers a Base settlement, instantly embedding the licensee's wallet address into the portrait's on-chain provenance. Pay for the rights you use, one frame at a time. Discipline: Photography (portrait photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from monolithic NFT minting to x402-metered licensing allows for 'micro-rights'—paying fractionally for social media vs. print usage, or individual headshot unlocks. It eliminates friction for the client while ensuring the photographer is paid per asset requested. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Prohibit" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-droneshot-ledger-14-x402 Title: SkyFloor · x402 Theme: Photography (photography) · drone photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized 'pay-per-view' sky gallery where drone pilots monetize high-resolution aerial assets. Every full-res download or metadata inspection costs 0.01 USDC. No subscriptions, just immediate micropayments to the pilot's HTS transfer authorized wallet. Prove provenance and profit at the pixel level. Why Hedera: By moving from a passive ledger to an x402-gated model, the 'provenance' becomes a liquid asset. The friction of credit cards for single-image rights is removed, enabling automated AI training sets or hobbyist collectors to pay-per-frame instantly on Hedera. Market: TAM $900M — The global stock photography and aerial data market transitioning to real-time, micro-transactional settlement. | SAM $140M — Professional drone photographers and commercial real estate agencies utilizing blockchain for asset management. | SOM $12M — Early-adopting Part 107 pilots and Base ecosystem collectors transacting via HashPack-enabled micro-licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SkyFloor" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized 'pay-per-view' sky gallery where drone pilots monetize high-resolution aerial assets. Every full-res download or metadata inspection costs 0.01 USDC. No subscriptions, just immediate micropayments to the pilot's HTS transfer authorized wallet. Prove provenance and profit at the pixel level. Discipline: Photography (drone photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a passive ledger to an x402-gated model, the 'provenance' becomes a liquid asset. The friction of credit cards for single-image rights is removed, enabling automated AI training sets or hobbyist collectors to pay-per-frame instantly on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SkyFloor" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-filtermint-chain-15-x402 Title: Prism · x402 Theme: Photography (photography) · creative filters Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Professional grade LUTs and generative shaders metered at the shutter. Mobile-first photography app where creators license custom color science at 0.01 USDC per export. No subscriptions; buy the 'look' only when you hit save. Why Hedera: Legacy filter apps use high-friction monthly subs for features users only use occasionally. x402 allows a high-volume, low-cost usage model where the payment signature is bundled with the image processing request. Market: TAM $4.2B — Global mobile photo editing and filters market moving toward micro-licensing models. | SAM $450M — High-end mobile photography enthusiasts and social media managers on Hedera. | SOM $12M — Early adopter mobile photographers looking for exclusive, chain-verifiable creator presets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Prism" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Professional grade LUTs and generative shaders metered at the shutter. Mobile-first photography app where creators license custom color science at 0.01 USDC per export. No subscriptions; buy the 'look' only when you hit save. Discipline: Photography (creative filters). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Legacy filter apps use high-friction monthly subs for features users only use occasionally. x402 allows a high-volume, low-cost usage model where the payment signature is bundled with the image processing request. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Prism" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-photostory-token-16-x402 Title: Expose · x402 Theme: Photography (photography) · photo narratives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A narrative-layer protocol where each paragraph of a photo's backstory is cryptographically gated. Viewers pay 0.01 USDC to 'develop' the next chapter of the visual essay, with payments flowing directly to the photographer's Magic Link email sign-in. No subscriptions; just pay-per-read provenance. Why Hedera: Transforms static metadata into a metered experience. By charging per narrative segment, it creates a new 'Micro-Substack' model for visual journalists and fine-art photographers, leveraging HTS transfer for friction-less multi-step storytelling. Market: TAM $2.8B — The global stock and editorial photography market transitioning to direct-to-consumer digital ownership. | SAM $450M — The digital collectibles and independent journalism market moving toward micro-transactions. | SOM $12M — Web3 travel photographers and war correspondents using Base for instant, verifiable narrative monetization. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Expose" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A narrative-layer protocol where each paragraph of a photo's backstory is cryptographically gated. Viewers pay 0.01 USDC to 'develop' the next chapter of the visual essay, with payments flowing directly to the photographer's Magic Link email sign-in. No subscriptions; just pay-per-read provenance. Discipline: Photography (photo narratives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transforms static metadata into a metered experience. By charging per narrative segment, it creates a new 'Micro-Substack' model for visual journalists and fine-art photographers, leveraging HTS transfer for friction-less multi-step storytelling. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Expose" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-360proof-chain-17-x402 Title: OmniView · x402 Theme: Photography (photography) · 360° photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Equip VR developers and real estate platforms with high-res spatial assets via a pay-per-view API. Users sign a 0.01 USDC request to decrypt and stream the 360-degree source file, ensuring photographers are paid instantly for every immersive glance. No subscriptions, just a micro-fee for every room explored or scene loaded. Why Hedera: Traditional stock photography fails in VR/AR because users want specific scenes without monthly overhead. By metering each 360-degree unlock, creators get paid per unique session, and developers avoid expensive licensing for assets that might never be viewed. Market: TAM $4.2B — The total addressable market for the global spatial computing and AR/VR content ecosystem. | SAM $850M — The market for licensed high-resolution 360-degree assets and spatial metadata for VR/metaverse apps. | SOM $12M — Micro-licensing for independent real estate agents and indie VR developers using HTS transfer for instant asset decryption. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "OmniView" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Equip VR developers and real estate platforms with high-res spatial assets via a pay-per-view API. Users sign a 0.01 USDC request to decrypt and stream the 360-degree source file, ensuring photographers are paid instantly for every immersive glance. No subscriptions, just a micro-fee for every room explored or scene loaded. Discipline: Photography (360° photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional stock photography fails in VR/AR because users want specific scenes without monthly overhead. By metering each 360-degree unlock, creators get paid per unique session, and developers avoid expensive licensing for assets that might never be viewed. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "OmniView" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-eventsnap-token-18-x402 Title: Vows · x402 Theme: Photography (photography) · wedding photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A distributed, 'guest-lens' protocol where every shutter click is a micro-transaction. Instead of a single photographer, the wedding host funds a pool that pays guests instantly for every high-res photo uploaded and verified. Alternatively, guests pay $0.01 to 'flash-unlock' the professional gallery live during the reception. Real-time settlement for the second-shooter economy. Why Hedera: Moving from static NFTs to flow-based settlement. By making the payment the trigger for high-res delivery via HTS transfer, we eliminate the need for post-event invoices and watermarked teasers. The 'flash-unlock' creates a social, gamified revenue stream during the event's peak emotional window. Market: TAM $74B — Global wedding services and event photography market. | SAM $850M — The addressable tech-spend layer for modern 'unplugged' weddings and high-end event production. | SOM $12M — Transaction volume from crypto-native wedding planners and boutique destination photographers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vows" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A distributed, 'guest-lens' protocol where every shutter click is a micro-transaction. Instead of a single photographer, the wedding host funds a pool that pays guests instantly for every high-res photo uploaded and verified. Alternatively, guests pay $0.01 to 'flash-unlock' the professional gallery live during the reception. Real-time settlement for the second-shooter economy. Discipline: Photography (wedding photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from static NFTs to flow-based settlement. By making the payment the trigger for high-res delivery via HTS transfer, we eliminate the need for post-event invoices and watermarked teasers. The 'flash-unlock' creates a social, gamified revenue stream during the event's peak emotional window. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vows" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-colorgrade-chain-19-x402 Title: Chromafuel · x402 Theme: Photography (photography) · color grading Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized colorist engine where every preset application is an on-chain event. Instead of buying static LUT packs, photographers pay $0.01 USDC per frame processed. The HTS transfer signature validates the 'Look' metadata, minting a temporary provenance proof for the grade. Professional colorists set up automated fee-streams—every time an AI agent or mobile editor applies their signature style, they are settled instantly on Hedera. Why Hedera: Moving from 'buy once' to 'pay per look' aligns with high-volume mobile editing and AI-generated imagery. x402 turns the preset into a metered service rather than a piratable file. Market: TAM $2.8B — Global digital photography and filter-based social commerce market. | SAM $450M — The creative professional software market shifting toward pay-as-you-go cloud credits. | SOM $12M — Transaction volume from mobile 'one-tap' editor integrations and AI image post-processing agents. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chromafuel" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized colorist engine where every preset application is an on-chain event. Instead of buying static LUT packs, photographers pay $0.01 USDC per frame processed. The HTS transfer signature validates the 'Look' metadata, minting a temporary provenance proof for the grade. Professional colorists set up automated fee-streams—every time an AI agent or mobile editor applies their signature style, they are settled instantly on Hedera. Discipline: Photography (color grading). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'buy once' to 'pay per look' aligns with high-volume mobile editing and AI-generated imagery. x402 turns the preset into a metered service rather than a piratable file. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chromafuel" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-wildlifemint-ledger-20-x402 Title: BioticLens · x402 Theme: Photography (photography) · wildlife photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Eliminate capture theft and AI imagery dilution. Every high-resolution shutter release or RAW download triggers a 0.01 USDC micro-royalty. Photographers set a metered 'view-to-own' gate where fans pay per high-fidelity pixel reveal, settling provenance and payment in a single Base transaction. It's not just an image; it's a paid stream of natural history. Why Hedera: Shifting from a one-time NFT mint to a per-view or per-download micropayment model ensures continuous monetization for field photographers while providing an immutable ledger of access history. Market: TAM $54B — The global digital photography and stock image distribution market. | SAM $1.2B — Professional wildlife and conservation media licensing. | SOM $40M — Decentralized nature photography platforms and independent creator direct-sales. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "BioticLens" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Eliminate capture theft and AI imagery dilution. Every high-resolution shutter release or RAW download triggers a 0.01 USDC micro-royalty. Photographers set a metered 'view-to-own' gate where fans pay per high-fidelity pixel reveal, settling provenance and payment in a single Base transaction. It's not just an image; it's a paid stream of natural history. Discipline: Photography (wildlife photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from a one-time NFT mint to a per-view or per-download micropayment model ensures continuous monetization for field photographers while providing an immutable ledger of access history. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "BioticLens" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-macrofocus-token-21-x402 Title: MacroFocus · x402 Theme: Photography (photography) · product photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 per high-res asset unlock. MacroFocus eliminates 'proof of work' friction for product photographers. Instead of invoices, brands pay-per-view to access full-resolution, forensic-grade product shots. Each micropayment triggers a Base settlement, granting instant usage rights via HTS transfer. Perfect for e-commerce scrapers, AI training sets, or wholesale catalog distribution. Why Hedera: By shifting from 'minting' to 'paying to access,' the protocol turns every image into a metered API endpoint. This creates a high-velocity marketplace where brands pay for what they use, and photographers earn real-time yield on their portfolio rather than waiting for bulk licensing deals. Market: TAM $4.8B — The global commercial photography and imaging market transitioning to on-chain provenance. | SAM $850M — The addressable market for decentralized e-commerce assets and AI-model training data licensing. | SOM $12M — The immediate volume from boutique product photographers and Shopify-integrated verification workflows. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MacroFocus" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 per high-res asset unlock. MacroFocus eliminates 'proof of work' friction for product photographers. Instead of invoices, brands pay-per-view to access full-resolution, forensic-grade product shots. Each micropayment triggers a Base settlement, granting instant usage rights via HTS transfer. Perfect for e-commerce scrapers, AI training sets, or wholesale catalog distribution. Discipline: Photography (product photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'minting' to 'paying to access,' the protocol turns every image into a metered API endpoint. This creates a high-velocity marketplace where brands pay for what they use, and photographers earn real-time yield on their portfolio rather than waiting for bulk licensing deals. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MacroFocus" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-photoset-chain-22-x402 Title: Aperture · x402 Theme: Photography (photography) · photo series Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless gallery for professional photo series where every 'High-Res View' or 'RAW metadata export' is metered via x402. Instead of subscriptions, collectors and AI scouts pay $0.01 per frame to unlock full-fidelity assets, creating a granular revenue stream for photographers that scales with every individual eyeshare. Why Hedera: By shifting from an all-or-nothing purchase to a pay-per-view (PPV) model at the primitive level, photographers capture value from casual browsers and AI scrapers alike. The HTS transfer signature turns every image load into a micro-transaction, ensuring authenticity is paid for, not just stated. Market: TAM $4.2B — The global market for digital image licensing, increasingly automated by AI agent curation. | SAM $850M — The addressable market for independent mobile photography sales and digital asset licensing. | SOM $12M — The market of on-chain photographers and curators using Base for verifiable digital provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Aperture" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless gallery for professional photo series where every 'High-Res View' or 'RAW metadata export' is metered via x402. Instead of subscriptions, collectors and AI scouts pay $0.01 per frame to unlock full-fidelity assets, creating a granular revenue stream for photographers that scales with every individual eyeshare. Discipline: Photography (photo series). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from an all-or-nothing purchase to a pay-per-view (PPV) model at the primitive level, photographers capture value from casual browsers and AI scrapers alike. The HTS transfer signature turns every image load into a micro-transaction, ensuring authenticity is paid for, not just stated. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Aperture" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-urbanview-token-23-x402 Title: Concrete · x402 Theme: Photography (photography) · urban photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity urban preservation protocol where every high-res capture is gated by a micropayment. Users pay 0.01 USDC to unlock raw metadata, verify GPS-provenance, or license street-level captures for commercial AI training and architectural modeling. Zero subscriptions; pay only for the pixels you process. Why Hedera: Traditional licensing is bogged down by high platform fees and 'all-you-can-eat' subscriptions that devalue specific shots. x402 enables a granular 'Pay-per-POV' economy, allowing street photographers to monetize individual high-traffic scenes (architecture, graffiti, urban transit) via instant, fractional USDC settlements. Market: TAM $1.2B — The global digital image licensing and asset verification market, increasingly moving toward per-use micro-licensing models for AI data training. | SAM $45M — The sub-sector of mobile-first street photographers and urban visionaries transitioning to digital ownership and direct-to-consumer licensing. | SOM $1.8M — Initial target: Urban explorers and architectural researchers on Hedera seeking verifiable, high-res scene data via 0.01 USDC micro-transactions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Concrete" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity urban preservation protocol where every high-res capture is gated by a micropayment. Users pay 0.01 USDC to unlock raw metadata, verify GPS-provenance, or license street-level captures for commercial AI training and architectural modeling. Zero subscriptions; pay only for the pixels you process. Discipline: Photography (urban photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is bogged down by high platform fees and 'all-you-can-eat' subscriptions that devalue specific shots. x402 enables a granular 'Pay-per-POV' economy, allowing street photographers to monetize individual high-traffic scenes (architecture, graffiti, urban transit) via instant, fractional USDC settlements. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Concrete" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA photography-nightshot-mint-24-x402 Title: LumenCheck · x402 Theme: Photography (photography) · night photography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An on-chain long-exposure engine that settles 0.01 USDC per frame to unlock professional-grade noise reduction and celestial metadata tagging. Instead of bulk uploads, users pay per shutter-trigger to verify the exact spatiotemporal coordinates of the shot, preventing AI-generated deepfakes in night sky competitions. Each exposure is a micro-transactional event that proves 'I was there, and this light is real.' Why Hedera: Transitioning from a bulk minting model to a pay-per-capture model ensures that high-compute image processing (like stacking or RAW denoising) is funded incrementally. It turns the camera into a meter, making provenance a byproduct of the act of shooting rather than an afterthought. Market: TAM $4.2B — Global digital photography and metadata verification sector. | SAM $180M — The enthusiast night-sky and astrophotography software market. | SOM $12M — Dedicated mobile night-photographers using HTS transfer enabled devices. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LumenCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An on-chain long-exposure engine that settles 0.01 USDC per frame to unlock professional-grade noise reduction and celestial metadata tagging. Instead of bulk uploads, users pay per shutter-trigger to verify the exact spatiotemporal coordinates of the shot, preventing AI-generated deepfakes in night sky competitions. Each exposure is a micro-transactional event that proves 'I was there, and this light is real.' Discipline: Photography (night photography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from a bulk minting model to a pay-per-capture model ensures that high-compute image processing (like stacking or RAW denoising) is funded incrementally. It turns the camera into a meter, making provenance a byproduct of the act of shooting rather than an afterthought. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LumenCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ============================================================================== THEME · Theater & Live Performance directors, playwrights, performers, lighting and stage designers ============================================================================== ------------------------------------------------------------------------------ IDEA theater-scriptchain-ledger-0-x402 Title: DraftFlow · x402 Theme: Theater & Live Performance (theater) · playwright collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-contribution script evolution. Every time a playwright pushes a new scene, dialogue tweak, or stage direction, the production pays a $0.01 micro-royalty. This turns the script into a living, metered asset where writers are paid for the act of creation in real-time. Producers unlock specific drafts or 'forks' of a play for rehearsal by paying a settled $0.01 fee per actor access, ensuring the creative sweat equity is instantly monetized and timestamped on-chain. Why Hedera: Shifts the focus from 'passive archiving' to 'active monetization of effort.' By making every edit/unlock a transaction, it creates a high-velocity micro-economy for playwrights during the development phase, rather than waiting for a backend royalty check. Market: TAM $2.8B — The total addressable creative writing and theatrical publishing industry, expanding into the AI-agent screenwriter market. | SAM $450M — The global playwriting and script consultancy market, including digital collaboration tools for theater labs. | SOM $12M — Early-adopter off-Broadway labs, university drama departments, and experimental decentralized theater troupes across Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DraftFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-contribution script evolution. Every time a playwright pushes a new scene, dialogue tweak, or stage direction, the production pays a $0.01 micro-royalty. This turns the script into a living, metered asset where writers are paid for the act of creation in real-time. Producers unlock specific drafts or 'forks' of a play for rehearsal by paying a settled $0.01 fee per actor access, ensuring the creative sweat equity is instantly monetized and timestamped on-chain. Discipline: Theater & Live Performance (playwright collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the focus from 'passive archiving' to 'active monetization of effort.' By making every edit/unlock a transaction, it creates a high-velocity micro-economy for playwrights during the development phase, rather than waiting for a backend royalty check. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DraftFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-roleauction-platform-1-x402 Title: StageRead · x402 Theme: Theater & Live Performance (theater) · casting marketplace Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency talent scout protocol where every audition tape submission and casting call 'side' download is a micro-settlement. Directors pay $0.01 to unlock a performer's reel, and actors pay $0.01 to commit their encrypted audition data to the chain. This replaces monthly subscription models with a 'pay-per-opportunity' architecture, preventing platform bloat and ensuring only high-intent interactions between talent and production. Why Hedera: Casting is currently plagued by talent agency gatekeepers and high monthly SaaS fees (Actors Access/Backstage). By atomizing the cost to individual interactions (the 'read'), we create a fluid, demand-driven market where agents can programmatically scout via bots. Market: TAM $4.5B — The global entertainment talent acquisition and agency market as it shifts toward automated, AI-assisted screening. | SAM $220M — The digital casting and talent management software market, specifically for independent film and off-Broadway theatre. | SOM $12M — Indie theater productions and student showcases seeking a low-overhead alternative to centralized casting platforms. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageRead" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency talent scout protocol where every audition tape submission and casting call 'side' download is a micro-settlement. Directors pay $0.01 to unlock a performer's reel, and actors pay $0.01 to commit their encrypted audition data to the chain. This replaces monthly subscription models with a 'pay-per-opportunity' architecture, preventing platform bloat and ensuring only high-intent interactions between talent and production. Discipline: Theater & Live Performance (casting marketplace). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Casting is currently plagued by talent agency gatekeepers and high monthly SaaS fees (Actors Access/Backstage). By atomizing the cost to individual interactions (the 'read'), we create a fluid, demand-driven market where agents can programmatically scout via bots. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageRead" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-stagelight-token-2-x402 Title: LumenSync · x402 Theme: Theater & Live Performance (theater) · lighting rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for lighting designers to meter the 'burn' of their intellectual property. Lighting consoles ping the x402 endpoint to unlock specific DMX map frames or complex Chamsys/MA3 sequence macros. Venues pay 0.01 USDC per cue-trigger or per-minute of 'look' duration, ensuring designers are compensated for every performance without complex manual auditing. Why Hedera: Traditional licensing is flat-fee and unenforceable; x402 turns lighting cues into real-time metered assets. By requiring a signed micro-payment for the decryption of the DMX stream or macro-execution, the light show itself becomes a streaming revenue asset for the creator. Market: TAM $2.1B — The global live entertainment production and stagecraft technology sector. | SAM $450M — The touring and theatrical equipment software market. | SOM $12M — Independent lighting designers and mid-scale fringe festivals transitioning to digital rights management. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LumenSync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for lighting designers to meter the 'burn' of their intellectual property. Lighting consoles ping the x402 endpoint to unlock specific DMX map frames or complex Chamsys/MA3 sequence macros. Venues pay 0.01 USDC per cue-trigger or per-minute of 'look' duration, ensuring designers are compensated for every performance without complex manual auditing. Discipline: Theater & Live Performance (lighting rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is flat-fee and unenforceable; x402 turns lighting cues into real-time metered assets. By requiring a signed micro-payment for the decryption of the DMX stream or macro-execution, the light show itself becomes a streaming revenue asset for the creator. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LumenSync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-promptchain-impro-3-x402 Title: CueBurn · x402 Theme: Theater & Live Performance (theater) · improvisation prompts Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Transform stage terror into creative flow with a high-fidelity improv engine. Every prompt request triggers a 0.01 USDC micro-settlement, ensuring the prompt is unique, cryptographically timestamped on Hedera, and owned by the performer. No more repeating 'Office' or 'Space Station'—pay per spark to ensure high-quality, non-redundant creative inputs. Set up 'Stage Tabs' where a director’s wallet fuels a session for the entire troupe. Why Hedera: Improv thrives on the 'gift' of the prompt; x402 turns that gift into a measurable units of creative energy. By metering the prompts, the app prevents spamming and creates a verifiable audit trail of a performer's range and quickness. Market: TAM $2.4B — The global performing arts education and live entertainment market transitioning to agent-assisted content. | SAM $120M — Professional improv theaters, comedy festivals, and collegiate troupes adopting digital-first creative tools. | SOM $8M — Individual performers and troupe leads on Hedera utilizing micropayment-gated practice sessions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CueBurn" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Transform stage terror into creative flow with a high-fidelity improv engine. Every prompt request triggers a 0.01 USDC micro-settlement, ensuring the prompt is unique, cryptographically timestamped on Hedera, and owned by the performer. No more repeating 'Office' or 'Space Station'—pay per spark to ensure high-quality, non-redundant creative inputs. Set up 'Stage Tabs' where a director’s wallet fuels a session for the entire troupe. Discipline: Theater & Live Performance (improvisation prompts). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Improv thrives on the 'gift' of the prompt; x402 turns that gift into a measurable units of creative energy. By metering the prompts, the app prevents spamming and creates a verifiable audit trail of a performer's range and quickness. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CueBurn" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-tickettrust-nft-4-x402 Title: MarqueeGate · x402 Theme: Theater & Live Performance (theater) · ticket authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A gate-entry protocol where every digital ticket validation is a 0.01 USDC micro-settlement. Instead of static QR codes, the 'GateCheck' requires an on-the-spot HTS transfer signature, paying the venue directly to unlock the turnstile. This prevents secondary market fraud by making the 'handshake' between the wallet and the scanner a real-time, paid proof-of-presence. Why Hedera: Traditional NFT tickets suffer from high gas fees and static metadata. By using x402, we turn the act of 'entry' into a pay-per-use primitive. It eliminates the need for expensive mints while ensuring every ticket scan is a verified, ledger-settled event on Hedera, making forgery economically impossible. Market: TAM $32B — The global event ticketing market transitioning to blockchain-based verification. | SAM $450M — The secondary market and ticketing verification niche for mid-size venues and festivals adopting Base. | SOM $12M — Initial rollout across boutique theaters and independent performance spaces using HashPack-auth entry. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "MarqueeGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A gate-entry protocol where every digital ticket validation is a 0.01 USDC micro-settlement. Instead of static QR codes, the 'GateCheck' requires an on-the-spot HTS transfer signature, paying the venue directly to unlock the turnstile. This prevents secondary market fraud by making the 'handshake' between the wallet and the scanner a real-time, paid proof-of-presence. Discipline: Theater & Live Performance (ticket authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT tickets suffer from high gas fees and static metadata. By using x402, we turn the act of 'entry' into a pay-per-use primitive. It eliminates the need for expensive mints while ensuring every ticket scan is a verified, ledger-settled event on Hedera, making forgery economically impossible. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "MarqueeGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-propchain-registry-5-x402 Title: Backstage Ledger · x402 Theme: Theater & Live Performance (theater) · prop ownership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographic registry for legendary stage props where every provenance check, ownership transfer, and 'playbill' update costs 0.01 USDC. The app meters the history of theater history—enabling props to accumulate value through a chain of verified performance signatures. No subscription; pay per update to keep the legacy alive. Why Hedera: By turning ownership updates and provenance queries into x402 micropayments, the value of a prop is directly linked to its 'active' history. It prevents database bloat and ensures that only intentional, paid ledger entries create the prop's permanent lore, turning metadata into a financial asset. Market: TAM $2.4B — Global theater production assets and historic archival collectibles market. | SAM $145M — Estimated secondary market for professional stage memorabilia and high-end collectibles. | SOM $12M — Indie theater houses and prop masters using specialized asset-tracking tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Backstage Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographic registry for legendary stage props where every provenance check, ownership transfer, and 'playbill' update costs 0.01 USDC. The app meters the history of theater history—enabling props to accumulate value through a chain of verified performance signatures. No subscription; pay per update to keep the legacy alive. Discipline: Theater & Live Performance (prop ownership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning ownership updates and provenance queries into x402 micropayments, the value of a prop is directly linked to its 'active' history. It prevents database bloat and ensures that only intentional, paid ledger entries create the prop's permanent lore, turning metadata into a financial asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Backstage Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-scriptroyalty-split-6-x402 Title: PromptPlay · x402 Theme: Theater & Live Performance (theater) · royalty distribution Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: The stage-play script is no longer a static PDF; it's a dynamic asset. Every time a director, student, or local theater group opens a digital scene for rehearsal, x402 settles a 0.05 USDC micro-royalty. This stream is instantly split via smart contract to the playwright, the translator, and the estate. No more chasing community theaters for licensing fees—payouts happen per-read, per-rehearsal, and per-performance unlock. Why Hedera: Traditional licensing is a friction-filled 'all or nothing' upfront cost. By metering script access at the 'scene' or 'read' level, we capture the massive long-tail of informal rehearsals and educational uses that currently go unpaid. transaction logic is embedded in the act of reading. Market: TAM $11B — Total global intellectual property royalty management for live performance. | SAM $1.2B — The licensing and royalty market for amateur and regional theater. | SOM $45M — Niche focus on digital script distribution for schools and experimental theater collectives. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PromptPlay" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT The stage-play script is no longer a static PDF; it's a dynamic asset. Every time a director, student, or local theater group opens a digital scene for rehearsal, x402 settles a 0.05 USDC micro-royalty. This stream is instantly split via smart contract to the playwright, the translator, and the estate. No more chasing community theaters for licensing fees—payouts happen per-read, per-rehearsal, and per-performance unlock. Discipline: Theater & Live Performance (royalty distribution). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is a friction-filled 'all or nothing' upfront cost. By metering script access at the 'scene' or 'read' level, we capture the massive long-tail of informal rehearsals and educational uses that currently go unpaid. transaction logic is embedded in the act of reading. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PromptPlay" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-stagecrew-dao-7-x402 Title: CueFlow · x402 Theme: Theater & Live Performance (theater) · crew coordination Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Operate stage logistics at the speed of light. Backstage crew pay 0.01 USDC to broadcast instant, high-priority cues or lock hardware interlocks (curtains, pyros, lifts). Every cue is an immutable tx hash, eliminating 'he-said-she-said' in high-stakes production environments. Paid signaling ensures zero noise on the comms line. Why Hedera: By shifting from 'governance' to 'metered execution,' the app solves the latency and accountability issues of live performance. Pay-per-cue creates a high-signal environment where every command is a signed, paid commitment. Market: TAM $4.2B — The global live event production and stage management software market. | SAM $850M — Focused on professional theater technical departments and touring music production crews using digital comms. | SOM $12M — Early adopters in the fringe theater and experimental digital-physical performance art scene on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CueFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Operate stage logistics at the speed of light. Backstage crew pay 0.01 USDC to broadcast instant, high-priority cues or lock hardware interlocks (curtains, pyros, lifts). Every cue is an immutable tx hash, eliminating 'he-said-she-said' in high-stakes production environments. Paid signaling ensures zero noise on the comms line. Discipline: Theater & Live Performance (crew coordination). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'governance' to 'metered execution,' the app solves the latency and accountability issues of live performance. Pay-per-cue creates a high-signal environment where every command is a signed, paid commitment. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CueFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-playmint-nft-8-x402 Title: TableRead · x402 Theme: Theater & Live Performance (theater) · script NFTs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes script repository where actors and directors pay 0.05 USDC per 'page-turn' or scene access. Instead of static NFTs, playwrights meter the reading experience, ensuring every rehearsal read or table-read session triggers a micro-settlement directly to the writer's wallet. Access is cryptographically signed, preventing unauthorized leaks of unreleased drafts. Why Hedera: Transitions from a static 'ownership' model to a fluid 'performance' model. Metaphysically aligning the payment with the act of reading/rehearsing turns the script into a live utility rather than a dormant asset. Market: TAM $2.1B — The global theatrical licensing and intellectual property market. | SAM $450M — Script licensing and digital distribution for regional theaters and indie productions. | SOM $12M — High-turnover table reads and developmental workshops for off-Broadway and fringe festivals. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TableRead" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes script repository where actors and directors pay 0.05 USDC per 'page-turn' or scene access. Instead of static NFTs, playwrights meter the reading experience, ensuring every rehearsal read or table-read session triggers a micro-settlement directly to the writer's wallet. Access is cryptographically signed, preventing unauthorized leaks of unreleased drafts. Discipline: Theater & Live Performance (script NFTs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitions from a static 'ownership' model to a fluid 'performance' model. Metaphysically aligning the payment with the act of reading/rehearsing turns the script into a live utility rather than a dormant asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TableRead" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-actorstake-platform-9-x402 Title: STANDING O · x402 Theme: Theater & Live Performance (theater) · performance staking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Audience members sign-to-approve 0.05 USDC micropayments per 'Ovation'. These small-batch tips are settled instantly to the performer's wallet during live scenes, creating a real-time heat map of audience engagement. Acts as a high-fidelity feedback loop where fans financially validate performance beats as they happen. Why Hedera: Transitions from a complex staking/escrow model to high-velocity micropayment 'ovations' that provide instant liquidity for the actor and direct influence for the fan. Market: TAM $2.1B — The global live performance and professional acting industry market. | SAM $450M — On-chain fans of digital/hybrid theater and livestreaming gala events. | SOM $12M — Early adopters in fringe theater festivals and indie creator livestreams on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STANDING O" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Audience members sign-to-approve 0.05 USDC micropayments per 'Ovation'. These small-batch tips are settled instantly to the performer's wallet during live scenes, creating a real-time heat map of audience engagement. Acts as a high-fidelity feedback loop where fans financially validate performance beats as they happen. Discipline: Theater & Live Performance (performance staking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitions from a complex staking/escrow model to high-velocity micropayment 'ovations' that provide instant liquidity for the actor and direct influence for the fan. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STANDING O" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-lightcue-automation-10-x402 Title: LuxGraph · x402 Theme: Theater & Live Performance (theater) · lighting cue logs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time synchronization layer for live performance. Pay 0.01 USDC to broadcast a signed, immutable lighting trigger or log a cue execution. Producers pay to verify show-timing accuracy for union compliance, while fans pay a micropayment to 'pulse' the lighting rig via a dedicated audience-interaction lane. Every state change is a settled transaction, ensuring the technical director’s log is cryptographically audit-proof. Why Hedera: By turning cue triggers into paid primitives, the app prevents spam interference in the DMX stream while creating a high-fidelity, trustless log of show execution for payroll and performance auditing. Market: TAM $2.1B — The global live event production and automated show-control market. | SAM $120M — Professional theaters, concert touring, and immersive 'pay-to-play' nightlife venues. | SOM $8.5M — Off-Broadway productions, regional technical directors, and experimental light-art festivals on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LuxGraph" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time synchronization layer for live performance. Pay 0.01 USDC to broadcast a signed, immutable lighting trigger or log a cue execution. Producers pay to verify show-timing accuracy for union compliance, while fans pay a micropayment to 'pulse' the lighting rig via a dedicated audience-interaction lane. Every state change is a settled transaction, ensuring the technical director’s log is cryptographically audit-proof. Discipline: Theater & Live Performance (lighting cue logs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning cue triggers into paid primitives, the app prevents spam interference in the DMX stream while creating a high-fidelity, trustless log of show execution for payroll and performance auditing. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LuxGraph" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-scenechange-nfts-11-x402 Title: StageFlow · x402 Theme: Theater & Live Performance (theater) · scene ownership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for 'performance rights as a service.' Directors and stage managers pay $0.01 USDC to unlock high-fidelity technical plots, lighting cues, and blocking notes for a single rehearsal or performance. Instead of bulky licensing deals, theaters pay per usage, streaming royalties directly to the original set designers and playwrights via the Base layer. Why Hedera: Shifts from 'ownership' to 'metered utility.' By making technical scene data low-friction and pay-per-use, it captures the high-frequency activity of rehearsals rather than just the one-time sale of a static asset. Market: TAM $1.8B — Global live performance production and IP licensing market. | SAM $120M — Digital licensing and royalty streams for independent and community theater. | SOM $2.5M — Emerging stage designers and fringe festival troupes utilizing x402 for affordable, modular tech plots. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for 'performance rights as a service.' Directors and stage managers pay $0.01 USDC to unlock high-fidelity technical plots, lighting cues, and blocking notes for a single rehearsal or performance. Instead of bulky licensing deals, theaters pay per usage, streaming royalties directly to the original set designers and playwrights via the Base layer. Discipline: Theater & Live Performance (scene ownership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from 'ownership' to 'metered utility.' By making technical scene data low-friction and pay-per-use, it captures the high-frequency activity of rehearsals rather than just the one-time sale of a static asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-playbill-ledger-12-x402 Title: StageTrace · x402 Theme: Theater & Live Performance (theater) · production documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A dynamic, pay-per-view production bible where every adjustment to the stage plot, lighting cue, or casting change is a metered event. Instead of a static PDF, the ledger is a living archive: researchers, fans, and future directors pay 0.01 USDC to unlock specific historical production snapshots or authenticated technical rider documents via Base. Why Hedera: By turning documentation into a granular, paid asset, production houses create a long-tail revenue stream from their creative process. x402 ensures that 'look-ups' of critical production data are micro-monetized, providing a transparent, on-chain royalty to the technical crew listed in the metadata. Market: TAM $1.2B — Global theater production, licensing, and archives management market. | SAM $180M — Digital documentation and archival budgets for professional regional theaters and touring productions. | SOM $12M — On-chain production credits for Off-Broadway and indie Fringe circuits seeking permanent, decentralized resumes. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A dynamic, pay-per-view production bible where every adjustment to the stage plot, lighting cue, or casting change is a metered event. Instead of a static PDF, the ledger is a living archive: researchers, fans, and future directors pay 0.01 USDC to unlock specific historical production snapshots or authenticated technical rider documents via Base. Discipline: Theater & Live Performance (production documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning documentation into a granular, paid asset, production houses create a long-tail revenue stream from their creative process. x402 ensures that 'look-ups' of critical production data are micro-monetized, providing a transparent, on-chain royalty to the technical crew listed in the metadata. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-audiencevote-dao-13-x402 Title: PROMPT · x402 Theme: Theater & Live Performance (theater) · live feedback Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A live performance steering engine. The stage script pauses at critical nodes; audience members sign a 0.01 USDC HTS transfer permit to cast a single, immutable vote via the the embedded wallet-embedded wallet. Performers react in real-time to the 'paid preference' stream, turning spectators into a distributed director's chair. $0.01 = 1 Influence. Why Hedera: Traditional DAO voting is too heavy for live theater. x402 enables low-friction, high-velocity 'micropayment-as-input,' ensuring only skin-in-the-game feedback influences the actors while creating a transparent, instant revenue stream for the troupe. Market: TAM $9.2B — Global live events and performing arts market transitioning to hybrid/interactive fan engagement. | SAM $450M — The performance art and experimental theater sector adopting interactive/gamified digital feedback tools. | SOM $12M — Off-Broadway and fringe festivals utilizing 'choose-your-own-adventure' formats with digital tipping/voting primitives. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PROMPT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A live performance steering engine. The stage script pauses at critical nodes; audience members sign a 0.01 USDC HTS transfer permit to cast a single, immutable vote via the the embedded wallet-embedded wallet. Performers react in real-time to the 'paid preference' stream, turning spectators into a distributed director's chair. $0.01 = 1 Influence. Discipline: Theater & Live Performance (live feedback). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional DAO voting is too heavy for live theater. x402 enables low-friction, high-velocity 'micropayment-as-input,' ensuring only skin-in-the-game feedback influences the actors while creating a transparent, instant revenue stream for the troupe. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PROMPT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-backstage-token-14-x402 Title: StageDoor · x402 Theme: Theater & Live Performance (theater) · access control Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Unlock high-security theater zones with instant micropayments. No manual scanning or guest-list friction—users pay 0.01 USDC via signed auth to rotate digital gate passes, trigger smart-locks, or verify VIP status via Base. Staff and performers are metered for attendance, while VIPs pay per entry to exclusive after-show lounges. Why Hedera: By replacing static tokens with x402 micropayments, the theater transforms access control into a live revenue and data feed. Every 'unlock' is a transaction, providing real-time heatmaps of staff movement and premium guest flow without the overhead of physical badges. Market: TAM $2.1B — Global live event security and VIP hospitality management. | SAM $420M — Professional theater houses and festival circuits adopting onchain credentialing. | SOM $12M — US-based independent theaters and experimental performance spaces. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageDoor" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Unlock high-security theater zones with instant micropayments. No manual scanning or guest-list friction—users pay 0.01 USDC via signed auth to rotate digital gate passes, trigger smart-locks, or verify VIP status via Base. Staff and performers are metered for attendance, while VIPs pay per entry to exclusive after-show lounges. Discipline: Theater & Live Performance (access control). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing static tokens with x402 micropayments, the theater transforms access control into a live revenue and data feed. Every 'unlock' is a transaction, providing real-time heatmaps of staff movement and premium guest flow without the overhead of physical badges. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageDoor" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-rehearsallog-chain-15-x402 Title: PromptBook · x402 Theme: Theater & Live Performance (theater) · rehearsal tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An x402-metered production slate where every stage manager note, blocking update, and attendance check-in is a precision micropayment. Pay 0.01 USDC per entry to immutably commit rehearsal logs to Base. Actors pay to 'unlock' their personalized daily notes; directors pay to 'blast' performance feedback. Accountability isn't a policy; it's a micro-transactional audit trail. Why Hedera: By turning notes into pay-per-use primitives, the rehearsal process gains financial discipline. Digital scarcity ensures only essential communication is broadcast, while the x402 model allows cast members to 'micro-tip' stagehands for exceptional support or pay to access premium archival footage. Market: TAM $1.8B — The worldwide performing arts economy, including film set management and live event logistics. | SAM $450M — The global professional theater and live events production management software market. | SOM $12M — Community theaters and professional pilot programs transitioning to agent-verified digital prompt books. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PromptBook" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An x402-metered production slate where every stage manager note, blocking update, and attendance check-in is a precision micropayment. Pay 0.01 USDC per entry to immutably commit rehearsal logs to Base. Actors pay to 'unlock' their personalized daily notes; directors pay to 'blast' performance feedback. Accountability isn't a policy; it's a micro-transactional audit trail. Discipline: Theater & Live Performance (rehearsal tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning notes into pay-per-use primitives, the rehearsal process gains financial discipline. Digital scarcity ensures only essential communication is broadcast, while the x402 model allows cast members to 'micro-tip' stagehands for exceptional support or pay to access premium archival footage. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PromptBook" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-scriptlens-dao-16-x402 Title: Cold Read · x402 Theme: Theater & Live Performance (theater) · script co-creation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A terminal for collaborative dramaturgy where every 'suggested change' is a micro-transactional commit. Playwrights post raw scenes; actors and directors pay 0.01 USDC to 'Enter the Room' (unlock a scene) or 'Mark the Script' (propose a line). Each payment triggers a Hedera transaction id that acts as a permanent, verifiable stamp of creative contribution, automating royalty distributions based on ledger activity rather than manual contracts. Why Hedera: Shifts the focus from slow DAO governance to real-time, high-velocity creative equity. By metering the 'edit' and 'view' functions, contributors build a provable stake in the intellectual property at the atomic level, turning the script into a living, liquid asset. Market: TAM $22B — The total addressable 'Creative IP Creation' economy, encompassing film, stage, and script-based entertainment. | SAM $850M — The global digital theatre production and script licensing market, embracing remote-collaboration tools for professional troupes. | SOM $12M — Early-adopter playwrights, developmental labs (like Sundance or O'Neill), and decentralized theater collectives using x402 for transparent revenue splits. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Cold Read" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A terminal for collaborative dramaturgy where every 'suggested change' is a micro-transactional commit. Playwrights post raw scenes; actors and directors pay 0.01 USDC to 'Enter the Room' (unlock a scene) or 'Mark the Script' (propose a line). Each payment triggers a Hedera transaction id that acts as a permanent, verifiable stamp of creative contribution, automating royalty distributions based on ledger activity rather than manual contracts. Discipline: Theater & Live Performance (script co-creation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the focus from slow DAO governance to real-time, high-velocity creative equity. By metering the 'edit' and 'view' functions, contributors build a provable stake in the intellectual property at the atomic level, turning the script into a living, liquid asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Cold Read" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-sceneswap-marketplace-17-x402 Title: SCENE_STAKE · x402 Theme: Theater & Live Performance (theater) · design exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity drafting table for set designers where every asset swap, CAD export, and lighting plot license is handled via 0.01 USDC micro-transactions. Eliminate high upfront licensing fees for independent theaters by metering usage: pay per scene download, per render, or per revision. Settlement is instant, allowing designers to monetize their 'scrap' sketches and finished builds in a liquid, high-frequency design exchange. Why Hedera: By shifting from lump-sum marketplace fees to x402 micropayments, SceneSwap lowers the barrier for community theaters to access professional-grade assets while creating a passive income stream for designers based on volume rather than one-off sales. Market: TAM $2.4B — Global stage design and theatrical equipment market. | SAM $120M — Digital assets and CAD software spend in global performing arts. | SOM $5M — Independent set designers and university drama departments utilizing micropayment-based asset licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SCENE_STAKE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity drafting table for set designers where every asset swap, CAD export, and lighting plot license is handled via 0.01 USDC micro-transactions. Eliminate high upfront licensing fees for independent theaters by metering usage: pay per scene download, per render, or per revision. Settlement is instant, allowing designers to monetize their 'scrap' sketches and finished builds in a liquid, high-frequency design exchange. Discipline: Theater & Live Performance (design exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from lump-sum marketplace fees to x402 micropayments, SceneSwap lowers the barrier for community theaters to access professional-grade assets while creating a passive income stream for designers based on volume rather than one-off sales. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SCENE_STAKE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-performerbadge-nft-18-x402 Title: StageGate · x402 Theme: Theater & Live Performance (theater) · credentials verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time verification layer for professional stage credits. Instead of static badges, production houses and agencies pay 0.01 USDC to instantly query a performer's cryptographically signed skills, certifications, and union status during casting or check-in. Per-call settlement turns trust into a high-frequency utility. Why Hedera: By shifting from a one-time mint to a pay-per-query model, the performer's data remains dynamic and monetized. It forces the 'verifier' to value the data, creating a sustainable ecosystem for credential maintenance without high upfront gas costs. Market: TAM $1.2B — The total addressable market for verifiable professional credentials in the global gig and creative economy. | SAM $280M — The global digital credentialing and background check market within the arts and entertainment sectors. | SOM $14M — Casting directors and talent agencies on Hedera looking for automated, fraud-proof skill verification scripts. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time verification layer for professional stage credits. Instead of static badges, production houses and agencies pay 0.01 USDC to instantly query a performer's cryptographically signed skills, certifications, and union status during casting or check-in. Per-call settlement turns trust into a high-frequency utility. Discipline: Theater & Live Performance (credentials verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a one-time mint to a pay-per-query model, the performer's data remains dynamic and monetized. It forces the 'verifier' to value the data, creating a sustainable ecosystem for credential maintenance without high upfront gas costs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-lightfx-tokenize-19-x402 Title: LUMENFLOW · x402 Theme: Theater & Live Performance (theater) · special effects licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A global inventory of stage atmospherics and lighting sequences. High-end lighting designers upload DMX scripts and pyro-timing logic. Touring productions pay 0.01 USDC per 'cue fire' or 'scene load' via HTS transfer. No more flat-fee piracy; pay strictly for the performances you stage. Why Hedera: Shifting from bulk licensing to per-performance metering ensures small regional theaters can afford world-class effects while elite designers capture tail-end revenue from every single show run globally. Market: TAM $1.4B — Global live event production and stage technology hardware/software integration. | SAM $210M — The lighting design and stage automation software market. | SOM $12M — Touring Broadway, West End, and festival circuit cue-triggering sessions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LUMENFLOW" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A global inventory of stage atmospherics and lighting sequences. High-end lighting designers upload DMX scripts and pyro-timing logic. Touring productions pay 0.01 USDC per 'cue fire' or 'scene load' via HTS transfer. No more flat-fee piracy; pay strictly for the performances you stage. Discipline: Theater & Live Performance (special effects licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from bulk licensing to per-performance metering ensures small regional theaters can afford world-class effects while elite designers capture tail-end revenue from every single show run globally. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LUMENFLOW" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-caststake-dao-20-x402 Title: Ovation · x402 Theme: Theater & Live Performance (theater) · fundraising Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Every micro-funding event is a real-time production contribution. Backers fund 'Standing Ovations'—micropayments that unlock live backstage feeds, script drafts, and set design polls via HTS transfer. Instead of large VC tranches, the play is sustained by thousands of $0.01 'Standing O' signatures, creating a streaming revenue model for live performance that settles instantly to the cast and crew's Magic Link email sign-ins. Why Hedera: Shifts theatrical funding from a high-friction investment model (DAO tokens) to a high-velocity consumption model. Using x402, fans 'pay-to-influence' or 'pay-to-view' in tiny increments, turning the audience into a real-time, micro-financing engine for independent performance. Market: TAM $28.5B — Global arts and entertainment crowdfunding and patron-based financing market. | SAM $4.2B — The total addressable 'off-Broadway' and independent theatre sector worldwide adopting micro-funding models. | SOM $12M — High-engagement experimental theater productions on Hedera leveraging real-time audience interaction. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Ovation" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Every micro-funding event is a real-time production contribution. Backers fund 'Standing Ovations'—micropayments that unlock live backstage feeds, script drafts, and set design polls via HTS transfer. Instead of large VC tranches, the play is sustained by thousands of $0.01 'Standing O' signatures, creating a streaming revenue model for live performance that settles instantly to the cast and crew's Magic Link email sign-ins. Discipline: Theater & Live Performance (fundraising). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts theatrical funding from a high-friction investment model (DAO tokens) to a high-velocity consumption model. Using x402, fans 'pay-to-influence' or 'pay-to-view' in tiny increments, turning the audience into a real-time, micro-financing engine for independent performance. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Ovation" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-propstoken-swap-21-x402 Title: StageDoor · x402 Theme: Theater & Live Performance (theater) · collaborative prop lending Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An on-demand logistics layer for regional theater clusters. Instead of messy spreadsheets or 'IOUs,' every prop in the shared warehouse is fitted with a digital twin and a secure storage locker. Borrowing companies pay a flat 0.01 USDC x402 fee to verify their insurance bond and generate a time-limited QR access code for pickup. The micro-fee automates the 'Checked Out' status, settles the liability smart contract, and funds the maintenance pool, making the collective inventory self-sustaining. Why Hedera: By replacing manual admin with x402 micropayments, the 'cost of coordination' drops to near zero. Payment acts as the digital handshake that formalizes the bailment agreement and updates the global state of the prop closet without back-and-forth emails. Market: TAM $22B — The global event production and live performance supply chain market. | SAM $850M — The equipment rental and production services segment for North American non-profit and regional theaters. | SOM $65M — Independent theater troupes and university drama departments in high-density urban arts hubs (NYC, London, Chicago). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageDoor" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An on-demand logistics layer for regional theater clusters. Instead of messy spreadsheets or 'IOUs,' every prop in the shared warehouse is fitted with a digital twin and a secure storage locker. Borrowing companies pay a flat 0.01 USDC x402 fee to verify their insurance bond and generate a time-limited QR access code for pickup. The micro-fee automates the 'Checked Out' status, settles the liability smart contract, and funds the maintenance pool, making the collective inventory self-sustaining. Discipline: Theater & Live Performance (collaborative prop lending). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing manual admin with x402 micropayments, the 'cost of coordination' drops to near zero. Payment acts as the digital handshake that formalizes the bailment agreement and updates the global state of the prop closet without back-and-forth emails. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageDoor" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-stagesound-dao-22-x402 Title: CuePoint · x402 Theme: Theater & Live Performance (theater) · sound design collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-sample. A live-remix soundboard for theater technicians. Sound designers upload stems to an open pool; stage managers or automation scripts pay 0.01 USDC to trigger or 'checkout' high-fidelity spatial audio cues in real-time. Revenue streams instantly to the designer's HTS transfer wallet, turning every 'Go' command into a micro-royalty event. Why Hedera: Traditional sound design involves flat fees and static files. By using x402, we shift to a 'metered performance' model where the use of sound during the actual run of a show sustains the creator. It incentivizes the creation of high-quality, reusable assets that can be triggered by agents or human operators. Market: TAM $2.4B — The broader 'Live Entertainment' sound design and royalty management sector including concerts and theme parks. | SAM $180M — The global theatrical sound equipment and content licensing market. | SOM $12M — Independent fringe festivals, off-Broadway productions, and digital theater startups adopting web3 middleware. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CuePoint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-sample. A live-remix soundboard for theater technicians. Sound designers upload stems to an open pool; stage managers or automation scripts pay 0.01 USDC to trigger or 'checkout' high-fidelity spatial audio cues in real-time. Revenue streams instantly to the designer's HTS transfer wallet, turning every 'Go' command into a micro-royalty event. Discipline: Theater & Live Performance (sound design collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional sound design involves flat fees and static files. By using x402, we shift to a 'metered performance' model where the use of sound during the actual run of a show sustains the creator. It incentivizes the creation of high-quality, reusable assets that can be triggered by agents or human operators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CuePoint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-encore-reward-23-x402 Title: STANDING OVATION · x402 Theme: Theater & Live Performance (theater) · audience loyalty Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time loyalty layer for live theater. Audiences authorize HTS transfer micropayments to 'Pulse-Vote' performance highlights via their mobile wallet. Each vote costs 0.01 USDC, instantly rewarding the cast and crew's smart contract while securing the user a 'Standing Ovation' rank. High-rank holders unlock exclusive meet-and-greets or front-row seat upgrades via automated x402 gates at the theater entrance. Why Hedera: Loyalty is traditionally passive. by turning 'applause' and 'loyalty' into an active, metered micropayment stream, theaters gain a new revenue vertical while performers receive instant on-chain tips, making the audience an active financial participant in the show's success. Market: TAM $37B — The global theatrical performance market and live entertainment fan-engagement industry. | SAM $180M — The digital engagement and VIP upsell slice of the US performing arts sector. | SOM $12M — Early adopter independent theaters and Off-Broadway venues utilizing crypto-native loyalty. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STANDING OVATION" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time loyalty layer for live theater. Audiences authorize HTS transfer micropayments to 'Pulse-Vote' performance highlights via their mobile wallet. Each vote costs 0.01 USDC, instantly rewarding the cast and crew's smart contract while securing the user a 'Standing Ovation' rank. High-rank holders unlock exclusive meet-and-greets or front-row seat upgrades via automated x402 gates at the theater entrance. Discipline: Theater & Live Performance (audience loyalty). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Loyalty is traditionally passive. by turning 'applause' and 'loyalty' into an active, metered micropayment stream, theaters gain a new revenue vertical while performers receive instant on-chain tips, making the audience an active financial participant in the show's success. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STANDING OVATION" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-setpiece-provenance-24-x402 Title: DRAMA PROOF · x402 Theme: Theater & Live Performance (theater) · historical archival Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Historical stagecraft is locked in prop rooms. Access the 'Living Script' for any authenticated set piece—blueprints, performance logs, and actor signatures—via per-query USDC micropayments. Whether you are a researcher verifying a 1920s Broadway chair or a director licensing a legacy design, every insight costs a cent. Direct-to-archive royalties for theaters. Why Hedera: Shifts from a passive 'record' to an active 'knowledge-as-a-service' model. The pay-per-query structure turns an archive into a revenue-generating API for set designers and historians. Market: TAM $680M — The global archival and provenance market for fine arts and performing arts artifacts. | SAM $45M — The niche market for high-end theater memorabilia, archival research, and design licensing. | SOM $2.8M — Professional stage designers, university drama departments, and collectors transacting via x402-enabled mobile wallets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DRAMA PROOF" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Historical stagecraft is locked in prop rooms. Access the 'Living Script' for any authenticated set piece—blueprints, performance logs, and actor signatures—via per-query USDC micropayments. Whether you are a researcher verifying a 1920s Broadway chair or a director licensing a legacy design, every insight costs a cent. Direct-to-archive royalties for theaters. Discipline: Theater & Live Performance (historical archival). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from a passive 'record' to an active 'knowledge-as-a-service' model. The pay-per-query structure turns an archive into a revenue-generating API for set designers and historians. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DRAMA PROOF" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-stagelight-archive-0-x402 Title: Lumen · x402 Theme: Theater & Live Performance (theater) · lighting design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-state lighting orchestration. Designers publish complex fixture profiles, cues, and DMX universes to IPFS; peers pay $0.01 USDC to unlock the setup code for local production or AI-driven pre-visualization. Why Hedera: Moving from a 'storage' model to a 'per-pull' model turns designs into liquid assets. It prevents scraping and ensures the original LD is compensated every time their aesthetic is 'referenced' or imported into a lighting console. Market: TAM $2.4B — Global live event production and architectural lighting software market. | SAM $140M — Licensing and IP for professional theatrical design. | SOM $12M — Independent LDs and touring crews using Base for instant asset retrieval. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Lumen" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-state lighting orchestration. Designers publish complex fixture profiles, cues, and DMX universes to IPFS; peers pay $0.01 USDC to unlock the setup code for local production or AI-driven pre-visualization. Discipline: Theater & Live Performance (lighting design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a 'storage' model to a 'per-pull' model turns designs into liquid assets. It prevents scraping and ensures the original LD is compensated every time their aesthetic is 'referenced' or imported into a lighting console. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Lumen" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-playscript-vault-1-x402 Title: Proscenium · x402 Theme: Theater & Live Performance (theater) · playwriting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.25 — Permissionless script storage meets pay-per-read distribution. Playwrights upload to IPFS and lock drafts behind x402 gates. Secure a scene, monetize a script doctoring session, or charge talent agencies for 'first-look' access. Every PDF download or version-view triggers a USDC payment directly to the author, turning intellectual property into a live, metered asset. Why Hedera: Shifts scripts from static files to revenue-generating assets. Using HTS transfer for friction-free 'stage door' access allows playwrights to bypass traditional literary agent gatekeeping for early distribution. Market: TAM $2.1B — The total addressable 'Creative IP' licensing market, covering scripts, librettos, and performance rights. | SAM $120M — The global playwriting and screenplay software and distribution market. | SOM $4.5M — Independent playwrights and fringe festival creators adopting web3-native licensing and distribution models. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proscenium" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.25 — Permissionless script storage meets pay-per-read distribution. Playwrights upload to IPFS and lock drafts behind x402 gates. Secure a scene, monetize a script doctoring session, or charge talent agencies for 'first-look' access. Every PDF download or version-view triggers a USDC payment directly to the author, turning intellectual property into a live, metered asset. Discipline: Theater & Live Performance (playwriting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts scripts from static files to revenue-generating assets. Using HTS transfer for friction-free 'stage door' access allows playwrights to bypass traditional literary agent gatekeeping for early distribution. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proscenium" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-costumemood-board-2-x402 Title: StitchFoundry · x402 Theme: Theater & Live Performance (theater) · costume design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized visual reference engine where every mood board asset is a pay-per-view 'look'. Designers earn 0.01 USDC instantly when a director or stylist unlocks a sketch, fabric swatch, or technical drawing via x402 signatures. No subscriptions, just a micro-settled ledger of costume inspiration. Why Hedera: Costume design often involves 'death by a thousand iterations.' Moving from free collaborative pinning to x402 micropayments protects IP and compensates designers for the research phase, not just the final garment. Market: TAM $14B — Global digital design collaboration and asset management market. | SAM $1.2B — Professional theater, film, and commercial production crews using digital design tools. | SOM $45M — Freelance costume designers and indie production houses on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StitchFoundry" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized visual reference engine where every mood board asset is a pay-per-view 'look'. Designers earn 0.01 USDC instantly when a director or stylist unlocks a sketch, fabric swatch, or technical drawing via x402 signatures. No subscriptions, just a micro-settled ledger of costume inspiration. Discipline: Theater & Live Performance (costume design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Costume design often involves 'death by a thousand iterations.' Moving from free collaborative pinning to x402 micropayments protects IP and compensates designers for the research phase, not just the final garment. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StitchFoundry" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-actorportfolio-hub-3-x402 Title: REELCAST · x402 Theme: Theater & Live Performance (theater) · performance showcase Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view casting vault where actors pay to pin their career to IPFS and casting directors pay to unlock high-res reels. No subscriptions, just 0.01 USDC to view a performance or drop a digital headshot into a production folder. Facilitator handles the HTS transfer signature, ensuring actors are compensated for 'audition data' and agents pay for access. Why Hedera: Legacy casting platforms lock talent behind monthly fees; this meters the interaction. By treating every click on a reel as a transaction, it filter-proofs the talent pool and creates a micro-economy for talent scouts. Market: TAM $1.2B — Global entertainer management and creative recruitment industry shifting toward decentralized portfolios. | SAM $140M — The digital talent acquisition and casting software market. | SOM $8M — Independent theater and film casting in the NYC/LA circuits utilizing Base for instant settlement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "REELCAST" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view casting vault where actors pay to pin their career to IPFS and casting directors pay to unlock high-res reels. No subscriptions, just 0.01 USDC to view a performance or drop a digital headshot into a production folder. Facilitator handles the HTS transfer signature, ensuring actors are compensated for 'audition data' and agents pay for access. Discipline: Theater & Live Performance (performance showcase). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Legacy casting platforms lock talent behind monthly fees; this meters the interaction. By treating every click on a reel as a transaction, it filter-proofs the talent pool and creates a micro-economy for talent scouts. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "REELCAST" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-setdesign-manifest-4-x402 Title: StageLedger · x402 Theme: Theater & Live Performance (theater) · set design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time coordination layer for stage craft where every design revision, lighting plot, and prop manifest is a metered unlock. Production crews pay 0.01 USDC to pull the latest 3D scene data or prop list, ensuring that every seat in the house—and Every hand on the crew—is working off the most valuable, paid-for version of the vision. No more outdated physical prints; just pay-per-pull precision. Why Hedera: By shifting from a static storage model to a pay-per-access manifest, we turn technical documentation into a micro-revenue stream for set designers. It prevents 'leaked' designs and ensures production houses value every iteration of the creative process while automating royalty splits for design reuse. Market: TAM $2.8B — Global live event production and theatre technology market seeking immutable, metered technical data standards. | SAM $450M — Touring Broadway and West End technical production budgets, including high-frequency equipment manifest updates. | SOM $12M — Specialized 3D set design firms and independent technical directors on Hedera seeking 'source of truth' synchronization. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageLedger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time coordination layer for stage craft where every design revision, lighting plot, and prop manifest is a metered unlock. Production crews pay 0.01 USDC to pull the latest 3D scene data or prop list, ensuring that every seat in the house—and Every hand on the crew—is working off the most valuable, paid-for version of the vision. No more outdated physical prints; just pay-per-pull precision. Discipline: Theater & Live Performance (set design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a static storage model to a pay-per-access manifest, we turn technical documentation into a micro-revenue stream for set designers. It prevents 'leaked' designs and ensures production houses value every iteration of the creative process while automating royalty splits for design reuse. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageLedger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-lightingcues-ledger-5-x402 Title: LumenStream · x402 Theme: Theater & Live Performance (theater) · lighting cues Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Synchronize live stage effects via pay-per-trigger event. Lighting designers publish cue sequences as encrypted x402-gated assets. Touring companies or automated consoles pay 1 cent to pull a specific cue block or trigger a pulse through the DMX-to-Base bridge, ensuring designers are paid per performance instance rather than a flat, leaky fee. Why Hedera: Traditional theater licensing is opaque. x402 turns lighting design into a 'Performance-as-a-Service' model where every 'Go' button press is a micro-transaction, allowing smaller venues to access high-end design on a per-use basis. Market: TAM $2.8B — Global live event production and stage automation market. | SAM $450M — The digital stagecraft and DMX lighting software market adopting smart-contract automation. | SOM $12M — Indie touring productions and experimental fringe theaters using automated cue triggering. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LumenStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Synchronize live stage effects via pay-per-trigger event. Lighting designers publish cue sequences as encrypted x402-gated assets. Touring companies or automated consoles pay 1 cent to pull a specific cue block or trigger a pulse through the DMX-to-Base bridge, ensuring designers are paid per performance instance rather than a flat, leaky fee. Discipline: Theater & Live Performance (lighting cues). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional theater licensing is opaque. x402 turns lighting design into a 'Performance-as-a-Service' model where every 'Go' button press is a micro-transaction, allowing smaller venues to access high-end design on a per-use basis. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LumenStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-playpromo-kit-6-x402 Title: StagePress · x402 Theme: Theater & Live Performance (theater) · marketing collateral Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn stage productions into tradeable media kits. Marketing teams and theaters pay 0.01 USDC per asset retrieval or high-res unlock. Every time a journalist or influencer downloads a play’s official promotional kit, the photographer and graphic designer receive instant, programmable settlement via Base. Payment proves intent and tracks genuine press outreach. Why Hedera: Shifts marketing from a 'cost center' to a 'metered asset distribution' model. It replaces dead download links with an active, paid provenance layer for theater press kits. Market: TAM $4.2B — The total addressable market for global entertainment PR and digital asset management (DAM) systems. | SAM $850M — Marketing and PR spending across global theatrical institutions and independent fringe festivals. | SOM $12M — Web3-integrated touring companies and digital-first theater marketing agencies using Base for asset management. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StagePress" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn stage productions into tradeable media kits. Marketing teams and theaters pay 0.01 USDC per asset retrieval or high-res unlock. Every time a journalist or influencer downloads a play’s official promotional kit, the photographer and graphic designer receive instant, programmable settlement via Base. Payment proves intent and tracks genuine press outreach. Discipline: Theater & Live Performance (marketing collateral). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts marketing from a 'cost center' to a 'metered asset distribution' model. It replaces dead download links with an active, paid provenance layer for theater press kits. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StagePress" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-scriptrevision-chain-7-x402 Title: LineGate · x402 Theme: Theater & Live Performance (theater) · script editing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes script repository where every revision, line-edit, and director's note is a metered transaction. Use x402 to pay-per-commit, ensuring that playwrights are compensated for every iteration and collaborators pay 0.01 USDC to unlock the latest golden master. No more 'Final_v2_edit.docx'—only cryptographically signed, paid updates. Why Hedera: By turning script versioning into a pay-per-use primitive, we solve the 'invisible labor' problem in dramaturgy. The x402 model ensures that every time a director or actor fetches a fresh edit from the blockchain, the writer receives an instant, micro-settlement, making the script a living, revenue-generating asset during the rehearsal process. Market: TAM $2.8B — Global theatrical production and screenplay intellectual property management. | SAM $450M — Independent playwrights and Off-Broadway production houses using digital script management. | SOM $12M — Experimental theater troupes and script consultants on Hedera seeking immutable, paid-access version control. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LineGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes script repository where every revision, line-edit, and director's note is a metered transaction. Use x402 to pay-per-commit, ensuring that playwrights are compensated for every iteration and collaborators pay 0.01 USDC to unlock the latest golden master. No more 'Final_v2_edit.docx'—only cryptographically signed, paid updates. Discipline: Theater & Live Performance (script editing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning script versioning into a pay-per-use primitive, we solve the 'invisible labor' problem in dramaturgy. The x402 model ensures that every time a director or actor fetches a fresh edit from the blockchain, the writer receives an instant, micro-settlement, making the script a living, revenue-generating asset during the rehearsal process. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LineGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-setprops-catalog-8-x402 Title: StageVault · x402 Theme: Theater & Live Performance (theater) · props management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular prop inventory where every high-res inspection, 3D scan view, or rental reservation is an atomic $0.01 transaction. Eliminates SaaS subscription friction for independent theaters, allowing them to monetize their physical archives through 'pay-per-peek' digital access and 'pay-to-reserve' micro-signatures. Why Hedera: By replacing a monthly subscription with a meter per interaction, it turns inventory browsing into a revenue stream for local theaters. HTS transfer auth ensures that every catalog check or inventory lock is cryptographically verified and instantly settled. Market: TAM $2.4B — The global theatrical supply and equipment rental economy. | SAM $500M — The creative asset management market for regional theaters, universities, and prop houses. | SOM $12M — Independent prop masters and collegiate theater departments using Hedera testnet for low-overhead asset tracking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageVault" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular prop inventory where every high-res inspection, 3D scan view, or rental reservation is an atomic $0.01 transaction. Eliminates SaaS subscription friction for independent theaters, allowing them to monetize their physical archives through 'pay-per-peek' digital access and 'pay-to-reserve' micro-signatures. Discipline: Theater & Live Performance (props management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing a monthly subscription with a meter per interaction, it turns inventory browsing into a revenue stream for local theaters. HTS transfer auth ensures that every catalog check or inventory lock is cryptographically verified and instantly settled. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageVault" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-soundscape-archive-9-x402 Title: EchoVault · x402 Theme: Theater & Live Performance (theater) · sound design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An industrial-grade foley and atmosphere engine for live theater. Sound designers call high-fidelity stems from a decentralized vault via x402 signatures. Designers earn per-cue royalties as frontline technicians trigger sounds in real-time during performance, ensuring every 'creak' or 'explosion' is a settled micro-transaction on the ledger. Why Hedera: Shifts from a passive archive to an active, pay-per-trigger performance tool. By using HTS transfer, theater companies avoid bulk subscriptions and instead meter their actual sound usage per show run. Market: TAM $280M — Global live entertainment, immersive experience, and theme park audio markets. | SAM $42M — Professional sound designers and regional theater production houses. | SOM $1.8M — Off-Broadway and experimental fringe festivals using automated MIDI-to-Base triggers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "EchoVault" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An industrial-grade foley and atmosphere engine for live theater. Sound designers call high-fidelity stems from a decentralized vault via x402 signatures. Designers earn per-cue royalties as frontline technicians trigger sounds in real-time during performance, ensuring every 'creak' or 'explosion' is a settled micro-transaction on the ledger. Discipline: Theater & Live Performance (sound design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from a passive archive to an active, pay-per-trigger performance tool. By using HTS transfer, theater companies avoid bulk subscriptions and instead meter their actual sound usage per show run. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "EchoVault" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-performancereview-ledger-10-x402 Title: Ovation · x402 Theme: Theater & Live Performance (theater) · audience feedback Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Monetize theatrical critique by requiring a 0.01 USDC micro-payment to post or verify a performance review. Each x402 'Applause' or 'Jeer' is an on-chain vote signed via the embedded wallet, creating a high-signal feedback loop where audience skin-in-the-game funds the theater’s next production in real-time. Why Hedera: Moving from passive IPFS storage to active x402 settlement turns feedback into a revenue stream. The 0.01 USDC cost acts as a sybil-resistance mechanism, ensuring reviews are from verified attendees while aggregating micro-donations directly to the performers. Market: TAM $2.1B — The global live performance and performing arts market seeking new monetization and feedback primitives. | SAM $120M — Digital ticketing and audience engagement solutions for the global independent theater scene. | SOM $4.5M — Experimental off-broadway and fringe festival circuits adopting web3-native funding models. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Ovation" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Monetize theatrical critique by requiring a 0.01 USDC micro-payment to post or verify a performance review. Each x402 'Applause' or 'Jeer' is an on-chain vote signed via the embedded wallet, creating a high-signal feedback loop where audience skin-in-the-game funds the theater’s next production in real-time. Discipline: Theater & Live Performance (audience feedback). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from passive IPFS storage to active x402 settlement turns feedback into a revenue stream. The 0.01 USDC cost acts as a sybil-resistance mechanism, ensuring reviews are from verified attendees while aggregating micro-donations directly to the performers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Ovation" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-lightingpalette-exchange-11-x402 Title: GEL-SYNC · x402 Theme: Theater & Live Performance (theater) · color study Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A lighting gels and hex-code marketplace where every palette retrieval is an atomic transaction. Lighting designers pay 0.01 USDC to unlock the metadata for a curated atmosphere (IPFS hash + DMX values), instantly compensating the original designer. Stop searching through forums; pay per palette to sync professional aesthetics directly to your lighting console. Why Hedera: The x402 primitive transforms a passive mood-board into a high-velocity asset exchange. By metering the 'unlock' of technical data (DMX/Hex) rather than just the preview image, it creates a sustainable micro-income stream for theatrical lighting designers during tech rehearsals. Market: TAM $1.2B — The global live entertainment and stage technology professional services market. | SAM $45M — The digital marketplace for theatrical production design and live event visuals. | SOM $2.8M — The niche of freelance lighting designers and touring VJs on Hedera testnet using automated toolsets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GEL-SYNC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A lighting gels and hex-code marketplace where every palette retrieval is an atomic transaction. Lighting designers pay 0.01 USDC to unlock the metadata for a curated atmosphere (IPFS hash + DMX values), instantly compensating the original designer. Stop searching through forums; pay per palette to sync professional aesthetics directly to your lighting console. Discipline: Theater & Live Performance (color study). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: The x402 primitive transforms a passive mood-board into a high-velocity asset exchange. By metering the 'unlock' of technical data (DMX/Hex) rather than just the preview image, it creates a sustainable micro-income stream for theatrical lighting designers during tech rehearsals. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GEL-SYNC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-venuemap-index-12-x402 Title: PROSCENIUM · x402 Theme: Theater & Live Performance (theater) · stage layout Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-query protocol for theater technical riders. Stage directors pay 0.05 USDC to pull immutable, version-controlled CAD layouts and lighting plots from IPFS. Designers earn real-time royalties every time a touring crew accesses the official 'source of truth' for a venue's spatial constraints. Eliminate PDF version-drift with micro-metered access to the master build. Why Hedera: Traditional technical riders are messy email attachments. By turning access into a metered HTS transfer transaction, the venue ensures that only authorized, paying contractors access sensitive facility data, while creating a fractional revenue stream for the original layout drafters. Market: TAM $2.1B — The global live event infrastructure and facility management market transitioned to on-chain credentials. | SAM $140M — The global technical theater and stage equipment software market. | SOM $6.5M — Independent touring circuits and regional fringe festivals requiring low-friction access to technical specs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PROSCENIUM" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-query protocol for theater technical riders. Stage directors pay 0.05 USDC to pull immutable, version-controlled CAD layouts and lighting plots from IPFS. Designers earn real-time royalties every time a touring crew accesses the official 'source of truth' for a venue's spatial constraints. Eliminate PDF version-drift with micro-metered access to the master build. Discipline: Theater & Live Performance (stage layout). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional technical riders are messy email attachments. By turning access into a metered HTS transfer transaction, the venue ensures that only authorized, paying contractors access sensitive facility data, while creating a fractional revenue stream for the original layout drafters. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PROSCENIUM" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-monologue-archive-13-x402 Title: REPERTOIRE · x402 Theme: Theater & Live Performance (theater) · performance repertoire Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: 0.01 USDC — Performance rights on-demand. A cryptographically secured repertoire library where actors pay-per-read to unlock verified scripts and the original director's notation layer hosted on IPFS. No subscriptions, just immediate access to one-of-a-kind audition material. Why Hedera: Shifts the archive from a passive storage site to an active 'vending machine' for performance data. By metering access, it creates a sustainable micro-royalty model for playwrights while giving actors low-friction access to professional prep notes. Market: TAM $2.1B — The global theatrical licensing and royalties infrastructure market. | SAM $140M — The training and prep market for professional actors, voice talent, and drama students globally. | SOM $8.5M — High-frequency audition prep cycles and agent-managed repertoire access for early career performers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "REPERTOIRE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT 0.01 USDC — Performance rights on-demand. A cryptographically secured repertoire library where actors pay-per-read to unlock verified scripts and the original director's notation layer hosted on IPFS. No subscriptions, just immediate access to one-of-a-kind audition material. Discipline: Theater & Live Performance (performance repertoire). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the archive from a passive storage site to an active 'vending machine' for performance data. By metering access, it creates a sustainable micro-royalty model for playwrights while giving actors low-friction access to professional prep notes. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "REPERTOIRE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-costumetexture-bank-14-x402 Title: SwatchCast · x402 Theme: Theater & Live Performance (theater) · fabric study Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity textile library for digital and physical stage design. Pay 0.01 USDC to unlock raw 8K macro-textures, weave patterns, and drape-physics data for a specific fabric. Designers earn USDC residuals ogni volta their scans are pulled for mood boards or 3D character modeling. Direct HTS transfer settlement ensures the source artist is paid instantly per asset accessed. Why Hedera: High-resolution textile scans are often gatekept by expensive subscription silos or pirate sites. x402 enables modular, per-swatch pricing that fits the 'project-based' budget of costume departments while providing a micro-royalty stream for fabric researchers. Market: TAM $2.8B — The global textile design and digital twin sampling market entering the on-chain economy. | SAM $450M — The digital specialized asset market for theatrical, cinematic, and game-engine costume design. | SOM $12M — Independent costume designers and boutique production houses on Hedera utilizing decentralized asset libraries. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SwatchCast" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity textile library for digital and physical stage design. Pay 0.01 USDC to unlock raw 8K macro-textures, weave patterns, and drape-physics data for a specific fabric. Designers earn USDC residuals ogni volta their scans are pulled for mood boards or 3D character modeling. Direct HTS transfer settlement ensures the source artist is paid instantly per asset accessed. Discipline: Theater & Live Performance (fabric study). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: High-resolution textile scans are often gatekept by expensive subscription silos or pirate sites. x402 enables modular, per-swatch pricing that fits the 'project-based' budget of costume departments while providing a micro-royalty stream for fabric researchers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SwatchCast" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-propblueprint-cache-15-x402 Title: STAGECRAFT · x402 Theme: Theater & Live Performance (theater) · prop design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-view access to professional prop schematics, 3D print assets, and electronics diagrams. Each 0.01 USDC unlock grants a verified download of a specific build component, ensuring master makers get paid instantly for every replica made. Artisans no longer pay monthly subs for libraries they don't use; they pay per assembly guide needed for their specific production. Why Hedera: Transitioning from a 'cache' to a metered 'blueprint-as-a-service' model enables a high-volume, low-friction marketplace for stagecraft IP. x402 allows for granular licensing where a user pays exactly for the component they are building. Market: TAM $2.8B — Global live entertainment equipment and design industry, including the rising 'prosumer' prop replica market. | SAM $450M — Digital assets and licensing within the global theatrical production and film set design market. | SOM $12M — Micro-licensing revenue from professional prop houses, independent 'makers', and cosplay hobbyists using Base for instant IP settlement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STAGECRAFT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-view access to professional prop schematics, 3D print assets, and electronics diagrams. Each 0.01 USDC unlock grants a verified download of a specific build component, ensuring master makers get paid instantly for every replica made. Artisans no longer pay monthly subs for libraries they don't use; they pay per assembly guide needed for their specific production. Discipline: Theater & Live Performance (prop design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from a 'cache' to a metered 'blueprint-as-a-service' model enables a high-volume, low-friction marketplace for stagecraft IP. x402 allows for granular licensing where a user pays exactly for the component they are building. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STAGECRAFT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-directorstoryboard-hub-16-x402 Title: STAGE-SEQUENCE · x402 Theme: Theater & Live Performance (theater) · storyboarding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless sequencing engine where every frame transition or storyboard 'push' scales via micropayments. Directors pay 0.01 USDC to commit a frame to the sequence; collaborators pay 0.01 USDC to pull the high-res IPFS manifest. It turns pre-visualization into a live, metered ledger of creative intent, ensuring the DP, Gaffer, and Lead Actor are always synced to the latest paid-up version of the vision. Why Hedera: Traditional hubs suffer from 'version hell' and free-rider collaboration. By making 'Sequence Appends' and 'Version Pulls' x402 calls, you create a high-integrity audit trail on Hedera where the sequence is defined by the payment stream. Market: TAM $4.2B — The global Live Entertainment and Film production services market. | SAM $650M — The global pre-visualization and animation software market expanding into decentralized production. | SOM $12M — Indie theater houses and commercial production boutiques adopting Base/HashPack for transparent crew-side deliverable tracking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STAGE-SEQUENCE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless sequencing engine where every frame transition or storyboard 'push' scales via micropayments. Directors pay 0.01 USDC to commit a frame to the sequence; collaborators pay 0.01 USDC to pull the high-res IPFS manifest. It turns pre-visualization into a live, metered ledger of creative intent, ensuring the DP, Gaffer, and Lead Actor are always synced to the latest paid-up version of the vision. Discipline: Theater & Live Performance (storyboarding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional hubs suffer from 'version hell' and free-rider collaboration. By making 'Sequence Appends' and 'Version Pulls' x402 calls, you create a high-integrity audit trail on Hedera where the sequence is defined by the payment stream. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STAGE-SEQUENCE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-lightingfixture-db-17-x402 Title: LumenData · x402 Theme: Theater & Live Performance (theater) · equipment catalog Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular spec-vault for live production. Pay 0.01 USDC to unlock a verified DMX map, photometrics report, or technical manual. Technicians on-site query the DB via signed payload—no subscriptions, just instant access to the data needed to patch a rig. 80% of the byte-fee flows to the original equipment manufacturer or cataloger. Why Hedera: Technical documentation is often gatekept by bulky paywalls or scattered across fragmented sites. x402 turns these assets into high-liquidity digital goods where the cost of a 'quick look' matches the value of the moment. Market: TAM $8.2B — Global Live Performance & Venue Production equipment industry. | SAM $450M — Annual spend on lighting hire, specification software, and technical consultancy in North America/EU. | SOM $12M — The immediate market of touring technicians and rental houses adopting 'pay-per-spec' for on-site troubleshooting. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LumenData" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular spec-vault for live production. Pay 0.01 USDC to unlock a verified DMX map, photometrics report, or technical manual. Technicians on-site query the DB via signed payload—no subscriptions, just instant access to the data needed to patch a rig. 80% of the byte-fee flows to the original equipment manufacturer or cataloger. Discipline: Theater & Live Performance (equipment catalog). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Technical documentation is often gatekept by bulky paywalls or scattered across fragmented sites. x402 turns these assets into high-liquidity digital goods where the cost of a 'quick look' matches the value of the moment. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LumenData" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-virtualstage-seeds-18-x402 Title: Scena · x402 Theme: Theater & Live Performance (theater) · virtual set design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time set-piece repository where designers stream high-fidelity scene manifests directly to local engines. Each asset 'summon' or lighting rig deployment triggers a 0.01 USDC micro-settlement. Stop buying bundles; pay for the exact props used during the live performance or rehearsal session. Facilitated via HTS transfer for zero-friction cues. Why Hedera: Traditional set design is gated by massive licensing fees or clunky manual transfers. x402 enables 'On-Demand Scenography' where the physical stage interacts with the digital library one asset at a time, allowing indie theaters to access world-class virtual builds for pennies per scene change. Market: TAM $2.1B — Global live theatrical production and immersive experience tech. | SAM $450M — The digital scenery and live broadcast integration market. | SOM $12M — Independent tech-forward theaters and hybrid Fringe festival performers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Scena" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time set-piece repository where designers stream high-fidelity scene manifests directly to local engines. Each asset 'summon' or lighting rig deployment triggers a 0.01 USDC micro-settlement. Stop buying bundles; pay for the exact props used during the live performance or rehearsal session. Facilitated via HTS transfer for zero-friction cues. Discipline: Theater & Live Performance (virtual set design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional set design is gated by massive licensing fees or clunky manual transfers. x402 enables 'On-Demand Scenography' where the physical stage interacts with the digital library one asset at a time, allowing indie theaters to access world-class virtual builds for pennies per scene change. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Scena" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-lightingpattern-archive-19-x402 Title: LUXER · x402 Theme: Theater & Live Performance (theater) · lighting choreography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized rig-control registry where lighting designers monetize their DMX/Art-Net sequences. Instead of static files, users pay $0.01 per pattern 'burst' to sync choreographed cues directly to their console. Lighting becomes a downloadable, pay-per-scene utility for touring acts and local venues. Why Hedera: By turning lighting patterns into metered assets, we move from a static archive to a live execution protocol. x402 handles the micro-licensing of cues, allowing designers to earn passive income every time a production triggers their specific 'strobe sequence' or 'ambient wash' via a signed HTS transfer request. Market: TAM $4.2B — Global stage lighting and smart architectural illumination market. | SAM $850M — The addressable touring and live event technology market adopting automated show control. | SOM $12M — Independent lighting designers and house technicians at mid-sized venues using standardized digital cues. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LUXER" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized rig-control registry where lighting designers monetize their DMX/Art-Net sequences. Instead of static files, users pay $0.01 per pattern 'burst' to sync choreographed cues directly to their console. Lighting becomes a downloadable, pay-per-scene utility for touring acts and local venues. Discipline: Theater & Live Performance (lighting choreography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning lighting patterns into metered assets, we move from a static archive to a live execution protocol. x402 handles the micro-licensing of cues, allowing designers to earn passive income every time a production triggers their specific 'strobe sequence' or 'ambient wash' via a signed HTS transfer request. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LUXER" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-performancemanifesto-20-x402 Title: ManifestoStore · x402 Theme: Theater & Live Performance (theater) · creative documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Institutional memory for the avant-garde. Document your artistic intent as an immutable, tamper-proof manifesto. 0.01 USDC to seal a manifesto to Base; 0.01 USDC to decrypt and cite a peer's statement. Turning creative ephemera into a permanent, verifiable performance ledger. Why Hedera: By moving documentation from free/lost files to paid micro-transactions, artistic intent gains a 'proof of stake.' The x402 primitive meters the entry into the collective canon, turning archival work into a decentralized, value-backed repository for researchers and collaborators. Market: TAM $820M — The global digital preservation and metadata standards market for cultural institutions and creative estates. | SAM $45M — Estimated annual spend on archival and documentation software within global performing arts NGOs and universities. | SOM $1.2M — Targeted capture of independent theater companies and physical performance labs seeking low-cost, permanent digital provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ManifestoStore" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Institutional memory for the avant-garde. Document your artistic intent as an immutable, tamper-proof manifesto. 0.01 USDC to seal a manifesto to Base; 0.01 USDC to decrypt and cite a peer's statement. Turning creative ephemera into a permanent, verifiable performance ledger. Discipline: Theater & Live Performance (creative documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving documentation from free/lost files to paid micro-transactions, artistic intent gains a 'proof of stake.' The x402 primitive meters the entry into the collective canon, turning archival work into a decentralized, value-backed repository for researchers and collaborators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ManifestoStore" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-stagesafety-logs-21-x402 Title: CurtainCall · x402 Theme: Theater & Live Performance (theater) · safety compliance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographic 'Black Box' for live performance. Crew members pay 0.01 USDC to sign-off on high-risk stage cues (pyro, fly-rigs, trapdoors) or log inspections. Each payment triggers an immutable on-chain record that proves compliance and provides ironclad liability protection for venues and local unions. Why Hedera: Shifting from a passive storage model to a 'Pay-to-Certify' model creates an audit trail that is financially backed. Making safety data an on-chain event ensures that protocols are followed in real-time before the curtain rises. Market: TAM $8.5B — Global event production compliance and workplace safety monitoring for live entertainment. | SAM $1.2B — Professional theater operations, concert tours, and regional performing arts centers in the US/EU. | SOM $45M — Liability insurance-conscious Broadway and West End venues requiring tamper-proof safety logs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CurtainCall" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographic 'Black Box' for live performance. Crew members pay 0.01 USDC to sign-off on high-risk stage cues (pyro, fly-rigs, trapdoors) or log inspections. Each payment triggers an immutable on-chain record that proves compliance and provides ironclad liability protection for venues and local unions. Discipline: Theater & Live Performance (safety compliance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from a passive storage model to a 'Pay-to-Certify' model creates an audit trail that is financially backed. Making safety data an on-chain event ensures that protocols are followed in real-time before the curtain rises. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CurtainCall" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-actorlinetracker-22-x402 Title: PromptCloud · x402 Theme: Theater & Live Performance (theater) · line memorization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless rehearsal prompt-engine. Pay $0.01 USDC per automated 'line cue' or 'scene analysis' delivered to your earbuds via a secure WebSocket. The app functions as a paid digital stage manager, where every line-read verification and AI-driven character beat is validated as a transaction. No subscription—actors only pay for the specific scenes they are drilling. Why Hedera: Memorization is a high-intensity, episodic workflow. Instead of a flat fee, x402 allows actors to 'meter' their rehearsal time, paying per line verified by the AI prompter. This aligns cost with the grueling repetitive nature of the craft and ensures creators of private scripts get paid per 'access event' or rehearsal cycle. Market: TAM $4.2B — The global creative education and performing arts tech market, increasingly shifting toward agentic AI assistance and micro-learning. | SAM $480M — Professional actors, musical theater performers, and voice talent globally utilizing digital script tools. | SOM $12M — The niche of tech-forward theater professionals and conservatory students using AI-driven auditory rehearsal aids on mobile devices. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PromptCloud" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless rehearsal prompt-engine. Pay $0.01 USDC per automated 'line cue' or 'scene analysis' delivered to your earbuds via a secure WebSocket. The app functions as a paid digital stage manager, where every line-read verification and AI-driven character beat is validated as a transaction. No subscription—actors only pay for the specific scenes they are drilling. Discipline: Theater & Live Performance (line memorization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Memorization is a high-intensity, episodic workflow. Instead of a flat fee, x402 allows actors to 'meter' their rehearsal time, paying per line verified by the AI prompter. This aligns cost with the grueling repetitive nature of the craft and ensures creators of private scripts get paid per 'access event' or rehearsal cycle. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PromptCloud" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-setlighting-simulator-23-x402 Title: LUXON · x402 Theme: Theater & Live Performance (theater) · previsualization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Render high-fidelity DMX-linked lighting mocks. Each frame generation or IPFS pin requires a signed micro-payment. Production crews pay per-view to sync the visualizer with the stage manager's master cue list. Light designers monetize their setup templates per download. Why Hedera: Shifts manual pre-viz from a flat subscription to a metered utility. By charging per-render/pin, it prevents server bloat and creates a direct value-link for technical directors paying for preview accuracy. x402 allows lighting rigs (IoT agents) to pull new scene data autonomously. Market: TAM $2.8B — Global live event production and stage technology software market. | SAM $420M — Professional theater, concert touring, and venue lighting design sectors. | SOM $18M — Independent lighting designers and regional theaters utilizing remote collaboration tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LUXON" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Render high-fidelity DMX-linked lighting mocks. Each frame generation or IPFS pin requires a signed micro-payment. Production crews pay per-view to sync the visualizer with the stage manager's master cue list. Light designers monetize their setup templates per download. Discipline: Theater & Live Performance (previsualization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts manual pre-viz from a flat subscription to a metered utility. By charging per-render/pin, it prevents server bloat and creates a direct value-link for technical directors paying for preview accuracy. x402 allows lighting rigs (IoT agents) to pull new scene data autonomously. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LUXON" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-playbill-archive-24-x402 Title: STAGEDATA · x402 Theme: Theater & Live Performance (theater) · program design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular digital library of theatrical design where every high-res zoom, metadata query, and asset download is an on-chain transaction. Users pay 0.01 USDC per page turn or inspiration-save, creating a sustainable royalty stream for original program designers while ensuring theatrical heritage is permanently archived on Hedera. Design student or AI training agent, you pay the creator directly for the reference. Why Hedera: Shifts from a passive archive to an active, metered reference library. By billing per 'view' or 'asset pull,' it protects the IP of the designers while removing the friction of a monthly subscription for casual researchers. Market: TAM $12B — The global theatrical production and cultural heritage archiving market, moving toward digitized, monetizable IP. | SAM $450M — The performance arts education and professional stage design market, encompassing design universities and theater marketing agencies. | SOM $1.8M — Active scenographers and design students utilizing metered asset access for mood-boarding and historical research. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STAGEDATA" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular digital library of theatrical design where every high-res zoom, metadata query, and asset download is an on-chain transaction. Users pay 0.01 USDC per page turn or inspiration-save, creating a sustainable royalty stream for original program designers while ensuring theatrical heritage is permanently archived on Hedera. Design student or AI training agent, you pay the creator directly for the reference. Discipline: Theater & Live Performance (program design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from a passive archive to an active, metered reference library. By billing per 'view' or 'asset pull,' it protects the IP of the designers while removing the friction of a monthly subscription for casual researchers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STAGEDATA" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-smartstage-access-0-x402 Title: Encore · x402 Theme: Theater & Live Performance (theater) · audience engagement Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn every audience reaction into a micro-transaction. Spectators pay 0.01 USDC to trigger stage lighting cues, vote on character choices in real-time, or unlock private BTS camera angles during the live performance. No gas, just signed intent via the embedded wallet, settling instantly on Hedera. Why Hedera: Traditional ticketing is a one-time gate; x402 turns the entire duration of the play into a monetizable stream where the audience pays per 'interaction' rather than a flat fee for static content. Market: TAM $75B — The global live events and theatrical attraction market. | SAM $450M — The digital sub-sector of live theater and experimental 'prosumer' performance art. | SOM $12M — Early-stage interactive theater troupes and immersive 'black box' venues using Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Encore" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn every audience reaction into a micro-transaction. Spectators pay 0.01 USDC to trigger stage lighting cues, vote on character choices in real-time, or unlock private BTS camera angles during the live performance. No gas, just signed intent via the embedded wallet, settling instantly on Hedera. Discipline: Theater & Live Performance (audience engagement). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional ticketing is a one-time gate; x402 turns the entire duration of the play into a monetizable stream where the audience pays per 'interaction' rather than a flat fee for static content. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Encore" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-scriptshare-vault-1-x402 Title: DRAMA · x402 Theme: Theater & Live Performance (theater) · script collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized script-doctoring engine where every revision, critique, and 'yes-and' is a metered micro-transaction. Pay 0.01 USDC to push a scene edit or unlock a peer's notes. Writers earn instantly as their contributions are accepted or read, turning the rehearsal room into a high-velocity, pay-per-line creative market. Final scripts are exported as authenticated, contribution-provenance PDFs once all micro-fees settle on Hedera. Why Hedera: Script writing is plagued by unpaid labor and opaque contribution credits. x402 weaponizes the 'revision' as a unit of value. By making edits cost $0.01, it discourages noise while rewarding quality contributors in real-time, replacing vague 'creative credit' with instant USDC settlement. Market: TAM $1.4B — The global theatre and cinematic intellectual property licensing market, increasingly automated via smart contracts. | SAM $180M — The market for professional screenplay/playwriting software and collaborative creative tools for remote production offices. | SOM $12M — Independent playwrights, Fringe Festival ensembles, and decentralized writers' rooms using Base for transparent IP attribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DRAMA" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized script-doctoring engine where every revision, critique, and 'yes-and' is a metered micro-transaction. Pay 0.01 USDC to push a scene edit or unlock a peer's notes. Writers earn instantly as their contributions are accepted or read, turning the rehearsal room into a high-velocity, pay-per-line creative market. Final scripts are exported as authenticated, contribution-provenance PDFs once all micro-fees settle on Hedera. Discipline: Theater & Live Performance (script collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Script writing is plagued by unpaid labor and opaque contribution credits. x402 weaponizes the 'revision' as a unit of value. By making edits cost $0.01, it discourages noise while rewarding quality contributors in real-time, replacing vague 'creative credit' with instant USDC settlement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DRAMA" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-propchain-ledger-2-x402 Title: PropCheck · x402 Theme: Theater & Live Performance (theater) · prop provenance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time provenance layer for stagecraft. Every prop scan (NFC/QR) or history update costs 0.01 USDC. Production designers pay-per-entry to log build specs, period authenticity, and 'hero prop' status. Theater fans pay 0.01 USDC to unlock the 'digital playbill' containing the private narrative arc and previous owners of iconic stage assets. No subscriptions, just a micro-fee for every verified hand-off. Why Hedera: By turning 'free transactions' into x402 micropayments, we monetize the high-velocity movement of props. In prestigious theater, provenance is the asset; a 0.01 USDC cost per 'touch' creates a high-integrity audit trail that prevents counterfeit stage assets while generating frictionless revenue for prop houses. Market: TAM $2.8B — The global live events and memorabilia tracking market, encompassing film, theater, and luxury display rentals. | SAM $450M — The specialized market for high-end theatrical rental houses and prop collectors requiring certified chain-of-custody. | SOM $12M — Initial rollout targeting off-Broadway productions and regional theater guilds using Base for budget transparency. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PropCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time provenance layer for stagecraft. Every prop scan (NFC/QR) or history update costs 0.01 USDC. Production designers pay-per-entry to log build specs, period authenticity, and 'hero prop' status. Theater fans pay 0.01 USDC to unlock the 'digital playbill' containing the private narrative arc and previous owners of iconic stage assets. No subscriptions, just a micro-fee for every verified hand-off. Discipline: Theater & Live Performance (prop provenance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'free transactions' into x402 micropayments, we monetize the high-velocity movement of props. In prestigious theater, provenance is the asset; a 0.01 USDC cost per 'touch' creates a high-integrity audit trail that prevents counterfeit stage assets while generating frictionless revenue for prop houses. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PropCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-lightcue-sync-3-x402 Title: LumenGate · x402 Theme: Theater & Live Performance (theater) · lighting design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-cue lighting orchestration. Designer-direct sync where every fader move or scene transition is an x402-metered state update. Stage managers pay 0.01 USDC to broadcast cues to the rig via the embedded wallet-signed HTS transfer auth, ensuring a tamper-proof, paid log of every performance shift on Hedera. Why Hedera: By making each cue a micropayment, we turn lighting design into a billable stream. It prevents 'feedback bloat' in rehearsals and ensures that the technical director is compensated for every precise adjustment made to the show file in real-time. Market: TAM $2.8B — Global live event production and architectural lighting control markets. | SAM $450M — Modernized theaters, touring houses, and immersive art installations adopting network-state tech. | SOM $12M — Independent fringe festivals and off-Broadway visual designers using Base for verifiable show-logs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LumenGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-cue lighting orchestration. Designer-direct sync where every fader move or scene transition is an x402-metered state update. Stage managers pay 0.01 USDC to broadcast cues to the rig via the embedded wallet-signed HTS transfer auth, ensuring a tamper-proof, paid log of every performance shift on Hedera. Discipline: Theater & Live Performance (lighting design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making each cue a micropayment, we turn lighting design into a billable stream. It prevents 'feedback bloat' in rehearsals and ensures that the technical director is compensated for every precise adjustment made to the show file in real-time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LumenGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-actortrust-ledger-4-x402 Title: Proscenium · x402 Theme: Theater & Live Performance (theater) · performance credentials Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: ActorTrust turns talent verification into a pay-per-call protocol. Casting directors pay $0.01 per query to pull HTS transfer signed, verified performance history directly from a performer's vault. No more bloated PDFs or unverified credits; actors earn a micropayment every time their 'Proof of Role' is accessed by an agency or production house. The credential itself serves as a programmable gated asset. Why Hedera: By moving from free storage to a micro-monetized query model, we eliminate data scraping and turn talent discovery into a direct revenue stream for the performer. The 0.01 USDC fee acts as a spam filter for casting calls and a dividend for the actor. Market: TAM $18B — Global entertainment HR and talent management ecosystem, including AI-driven background checks. | SAM $2.4B — Professional casting and agency subscription market transitioning to on-chain verification. | SOM $15M — Initial capture of SAG-AFTRA and Equity performers requiring secure, portable digital credentials. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proscenium" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT ActorTrust turns talent verification into a pay-per-call protocol. Casting directors pay $0.01 per query to pull HTS transfer signed, verified performance history directly from a performer's vault. No more bloated PDFs or unverified credits; actors earn a micropayment every time their 'Proof of Role' is accessed by an agency or production house. The credential itself serves as a programmable gated asset. Discipline: Theater & Live Performance (performance credentials). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from free storage to a micro-monetized query model, we eliminate data scraping and turn talent discovery into a direct revenue stream for the performer. The 0.01 USDC fee acts as a spam filter for casting calls and a dividend for the actor. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proscenium" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-sceneswap-marketplace-5-x402 Title: SceneSwap · x402 Theme: Theater & Live Performance (theater) · set design exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity CAD vault for scenographers. Pay-per-view to unlock detailed blueprints, technical plots, and 3D renders. Every 'Deep Look' into a master set design costs 0.01 USDC, instantly rewarding the original designer. Stop giving away technical IP for free; meter the inspiration. Why Hedera: By shifting from 'trading' to 'metered access,' the platform protects designer intellectual property. x402 allows designers to earn passively every time their work is referenced or studied, turning a static portfolio into a streaming revenue stream. Market: TAM $4.2B — Global stage production and live event design industry. | SAM $850M — Independent theater companies and regional Broadway-touring circuits. | SOM $12M — Set design students and freelance scenographers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SceneSwap" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity CAD vault for scenographers. Pay-per-view to unlock detailed blueprints, technical plots, and 3D renders. Every 'Deep Look' into a master set design costs 0.01 USDC, instantly rewarding the original designer. Stop giving away technical IP for free; meter the inspiration. Discipline: Theater & Live Performance (set design exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'trading' to 'metered access,' the platform protects designer intellectual property. x402 allows designers to earn passively every time their work is referenced or studied, turning a static portfolio into a streaming revenue stream. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SceneSwap" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-playbill-nfts-6-x402 Title: Staged · x402 Theme: Theater & Live Performance (theater) · memorabilia Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-local memorabilia vault where the physical ticket stub generates a one-time HTS transfer signature. Fans pay 0.05 USDC to 'Deep-Link' their performance: unlocking the full digital cast recording, behind-the-scenes rehearsal footage, and a cryptographically signed high-res playbill. No free mints; every interaction is a direct micropayment to the production's treasury. Fans can pay 0.01 USDC to 'Spotlight' a specific actor, sending a micro-tip directly to the artist's wallet while adding a gold border to their digital keepsake. Why Hedera: Shifts 'free mint' collectibles into high-velocity micro-transactions. By charging for the 'unlock' rather than the 'mint,' it bypasses gas-heavy hurdles and creates a direct, metered relationship between the audience and the cast members. Market: TAM $14B — Global live performance ticketing and ancillary merchandise market integrated with agentic commerce. | SAM $850M — The secondary market for Broadway/West End memorabilia and limited edition programs. | SOM $12M — The first-year micro-capture from 50 major touring productions utilizing per-asset unlocks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Staged" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-local memorabilia vault where the physical ticket stub generates a one-time HTS transfer signature. Fans pay 0.05 USDC to 'Deep-Link' their performance: unlocking the full digital cast recording, behind-the-scenes rehearsal footage, and a cryptographically signed high-res playbill. No free mints; every interaction is a direct micropayment to the production's treasury. Fans can pay 0.01 USDC to 'Spotlight' a specific actor, sending a micro-tip directly to the artist's wallet while adding a gold border to their digital keepsake. Discipline: Theater & Live Performance (memorabilia). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts 'free mint' collectibles into high-velocity micro-transactions. By charging for the 'unlock' rather than the 'mint,' it bypasses gas-heavy hurdles and creates a direct, metered relationship between the audience and the cast members. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Staged" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-audiencevote-live-7-x402 Title: DIRECTORLIT · x402 Theme: Theater & Live Performance (theater) · interactive performances Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time performance steering engine where every plot twist is bought, not just voted for. Audience members sign a 0.01 USDC authorization to trigger a 'Chaos Event' or 'Script Pivot'. The actor’s earpiece or stage lighting responds instantly to the highest-funded path. No gas, no friction, just paid democratic control of the stage. Why Hedera: Shifting from 'free voting' to 'micropayment-steering' turns the audience into active stakeholders. x402 eliminates the latency of traditional crypto transactions, allowing the performance to react in real-time to the flow of USDC. Market: TAM $16.5B Global Live Entertainment market adopting hybrid/interactive digital primitives. | SAM $2.8B global theater technology and experimental performance market. | SOM $45M across immersive theater residencies (e.g., Sleep No More style) and high-stakes livestreamed interactive events. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DIRECTORLIT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time performance steering engine where every plot twist is bought, not just voted for. Audience members sign a 0.01 USDC authorization to trigger a 'Chaos Event' or 'Script Pivot'. The actor’s earpiece or stage lighting responds instantly to the highest-funded path. No gas, no friction, just paid democratic control of the stage. Discipline: Theater & Live Performance (interactive performances). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from 'free voting' to 'micropayment-steering' turns the audience into active stakeholders. x402 eliminates the latency of traditional crypto transactions, allowing the performance to react in real-time to the flow of USDC. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DIRECTORLIT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-backstage-pass-8-x402 Title: GreenRoom · x402 Theme: Theater & Live Performance (theater) · exclusive access Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time, high-fidelity window into the wings. Fans pay 0.01 USDC per minute of 'Backstage' POV stream access. No subscriptions, no credit cards—just a signed HTS transfer message for every heartbeat of the performance. The facilitator settles the batch of signatures on Hedera, ensuring the production team gets paid for every second of engagement. Why Hedera: Transitioning from a 'sponsored pass' to a 'metered stream' turns the backstage experience into a liquid commodity. By using 0.01 USDC micropayments, users can drop in for a quick peek during a costume change or stay for the whole show without a large upfront commitment, maximizing theater revenue via granularity. Market: TAM $2.4B — The global live performance 'add-on' and VIP experiences market. | SAM $320M — Digital ticketing and VIP supplemental revenue for Broadway and West End productions. | SOM $12M — Early-adopter immersive theater fans and niche festival attendees using Base-integrated wallets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GreenRoom" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time, high-fidelity window into the wings. Fans pay 0.01 USDC per minute of 'Backstage' POV stream access. No subscriptions, no credit cards—just a signed HTS transfer message for every heartbeat of the performance. The facilitator settles the batch of signatures on Hedera, ensuring the production team gets paid for every second of engagement. Discipline: Theater & Live Performance (exclusive access). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from a 'sponsored pass' to a 'metered stream' turns the backstage experience into a liquid commodity. By using 0.01 USDC micropayments, users can drop in for a quick peek during a costume change or stay for the whole show without a large upfront commitment, maximizing theater revenue via granularity. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GreenRoom" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-rehearsalrecord-9-x402 Title: CueBack · x402 Theme: Theater & Live Performance (theater) · performance archives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity performance vault for regional theaters and Broadway archival teams. Ditch subscriptions for granular access. Pay 0.01 USDC to stream a specific rehearsal take, unlock a choreographer's blocking note, or retrieve a costume plot. Secure, instant archival monetization that turns 'lost' footage into a permanent, paid reference library for the cast and crew. Why Hedera: Shifting from 'free updates' to 'pay-per-access' ensures that high-bandwidth video storage is subsidized by the users consuming the content. x402 allows production houses to monetize their process, not just the final show. Market: TAM $1.4B — The global live performance documentation and educational reference market. | SAM $280M — The digital archival and asset management market for global theater, dance, and opera companies. | SOM $12M — Specialized archival retrieval for off-Broadway productions and university drama conservatories using automated micropayments. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CueBack" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity performance vault for regional theaters and Broadway archival teams. Ditch subscriptions for granular access. Pay 0.01 USDC to stream a specific rehearsal take, unlock a choreographer's blocking note, or retrieve a costume plot. Secure, instant archival monetization that turns 'lost' footage into a permanent, paid reference library for the cast and crew. Discipline: Theater & Live Performance (performance archives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from 'free updates' to 'pay-per-access' ensures that high-bandwidth video storage is subsidized by the users consuming the content. x402 allows production houses to monetize their process, not just the final show. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CueBack" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-sponsorspotlight-10-x402 Title: Spotlight · x402 Theme: Theater & Live Performance (theater) · performance sponsorship Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular sponsorship engine where brands pay-per-mention or pay-per-frame. Instead of bulk seasonal contracts, performers gate specific show moments (opening monologues, set-piece reveals, curtain calls) behind 0.01 USDC micro-sponsorships. Fans or local businesses stream payments in real-time to trigger on-stage digital displays or shout-outs, settled instantly to the performer's wallet. Why Hedera: Traditional sponsorship is lumpy and formal. x402 turns live performance into a metered inventory of 'attention slots,' allowing high-velocity micro-deals that bypass legal friction and aggregate into meaningful revenue for indie theater. Market: TAM $14.5B - The global event sponsorship market shifting toward digitized, granular attribution and automated payouts. | SAM $850M - Digital-native experimental theater, fringe festivals, and live-streamed performance art. | SOM $12M - Boutique performance spaces in tech hubs using decentralized infrastructure for show funding. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Spotlight" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular sponsorship engine where brands pay-per-mention or pay-per-frame. Instead of bulk seasonal contracts, performers gate specific show moments (opening monologues, set-piece reveals, curtain calls) behind 0.01 USDC micro-sponsorships. Fans or local businesses stream payments in real-time to trigger on-stage digital displays or shout-outs, settled instantly to the performer's wallet. Discipline: Theater & Live Performance (performance sponsorship). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional sponsorship is lumpy and formal. x402 turns live performance into a metered inventory of 'attention slots,' allowing high-velocity micro-deals that bypass legal friction and aggregate into meaningful revenue for indie theater. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Spotlight" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-roleswap-network-11-x402 Title: RoleSwap · x402 Theme: Theater & Live Performance (theater) · cast management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes marketplace for professional understudies and ensemble members to trade shifts or acquire performance slots. Every role 'claim' or 'release' is a cryptographically signed transaction, ensuring zero ambiguity in cast changes. Performers pay 0.01 USDC to broadcast an availability swap or to instantly lock a confirmed replacement, creating a micro-metered audit trail for stage managers and unions. Why Hedera: By replacing 'free' social coordination with x402 micropayments, the app creates a 'skin in the game' environment for performance reliability. The fee acts as a spam filter for role-grabs while providing the producer with an immutable, paid log of contract fulfillment changes. Market: TAM $2.1B — The global live theater and performing arts labor market. | SAM $420M — US regional and community theater payroll and management overhead. | SOM $12M — Off-Broadway and touring production cast-management costs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RoleSwap" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes marketplace for professional understudies and ensemble members to trade shifts or acquire performance slots. Every role 'claim' or 'release' is a cryptographically signed transaction, ensuring zero ambiguity in cast changes. Performers pay 0.01 USDC to broadcast an availability swap or to instantly lock a confirmed replacement, creating a micro-metered audit trail for stage managers and unions. Discipline: Theater & Live Performance (cast management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing 'free' social coordination with x402 micropayments, the app creates a 'skin in the game' environment for performance reliability. The fee acts as a spam filter for role-grabs while providing the producer with an immutable, paid log of contract fulfillment changes. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RoleSwap" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-stagedesign-dao-12-x402 Title: Proscenium · x402 Theme: Theater & Live Performance (theater) · design collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity CAD/3D render workbench for stage designers. Every 'Commit' to the collaborative workspace or 'View' of a technical draft requires a 0.01 USDC micropayment. Designers earn real-time royalties as collaborators fork their lighting plots or scenic elements. No governance bloat—just a pay-per-iterative-step environment where the best technical assets are instantly monetized. Why Hedera: Moving from 'gasless voting' (which is financially opaque) to 'pay-per-action' creates a high-signal design environment. By metring the 'Commit' and 'Export' actions, the DAO is replaced by an automated, fluid economy where the most-used design elements generate immediate USDC flow to their creators via the facilitator. Market: TAM $2.4B — The global live entertainment production and collaborative architecture tooling market. | SAM $450M — The digital stage design, VR scenography, and technical theater rendering market. | SOM $12M — Freelance lighting, set, and projection designers using Base/HashPack for rapid iteration and instant asset-level monetization. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proscenium" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity CAD/3D render workbench for stage designers. Every 'Commit' to the collaborative workspace or 'View' of a technical draft requires a 0.01 USDC micropayment. Designers earn real-time royalties as collaborators fork their lighting plots or scenic elements. No governance bloat—just a pay-per-iterative-step environment where the best technical assets are instantly monetized. Discipline: Theater & Live Performance (design collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'gasless voting' (which is financially opaque) to 'pay-per-action' creates a high-signal design environment. By metring the 'Commit' and 'Export' actions, the DAO is replaced by an automated, fluid economy where the most-used design elements generate immediate USDC flow to their creators via the facilitator. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proscenium" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-scripttoken-rights-13-x402 Title: DRAMA · x402 Theme: Theater & Live Performance (theater) · rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular rights-metering API for the stage. Instead of bulk licenses, small-scale productions and student troupes pay 0.01 USDC per page view or per script printed. the embedded wallet-signed sessions authorize each 'performance unlock,' automatically routing royalties to playwrights. Turn script access from a static PDF into a live, metered asset that allows creators to monetize every rehearsal read. Why Hedera: Live performance rights are currently gated by clunky legacy portals and high flat fees. by shifting to an x402-native per-view or per-print model, you lower the barrier for experimental theater while ensuring playwrights receive instantaneous, micro-settled compensation. Market: TAM $2.8B — The global theatrical publishing and performance rights industry. | SAM $450M — The digital licensing and educational script market for independent and community theater. | SOM $12M — High-frequency, per-page digital script access for fringe festivals and university drama departments. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DRAMA" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular rights-metering API for the stage. Instead of bulk licenses, small-scale productions and student troupes pay 0.01 USDC per page view or per script printed. the embedded wallet-signed sessions authorize each 'performance unlock,' automatically routing royalties to playwrights. Turn script access from a static PDF into a live, metered asset that allows creators to monetize every rehearsal read. Discipline: Theater & Live Performance (rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Live performance rights are currently gated by clunky legacy portals and high flat fees. by shifting to an x402-native per-view or per-print model, you lower the barrier for experimental theater while ensuring playwrights receive instantaneous, micro-settled compensation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DRAMA" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-cuepoint-tracker-14-x402 Title: Ghostlight · x402 Theme: Theater & Live Performance (theater) · live direction Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Direct the stage from your wallet. Every lighting change, set move, or sound trigger is an immutable instruction authorized by a 0.01 USDC micro-payment. It turns stage management into a high-integrity ledger, allowing designers to get paid instantly per update while ensuring the 'Official Show File' is a verifiable sequence of paid signatures. No payment, no cue—eliminating accidental triggers and ghost-running. Why Hedera: By turning 'Stage Cues' into x402 calls, the app moves performance data from volatile local software to a metered, permanent record. It introduces a 'Pay-per-Cue' model for guest designers and automated licensing for choreography. Market: TAM $5.2B — Global live event production and automated stage management systems. | SAM $850M — The digital infrastructure market for professional regional theaters and global touring productions. | SOM $12M — Freelance lighting and sound technicians using HashPack-enabled mobile rigs on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Ghostlight" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Direct the stage from your wallet. Every lighting change, set move, or sound trigger is an immutable instruction authorized by a 0.01 USDC micro-payment. It turns stage management into a high-integrity ledger, allowing designers to get paid instantly per update while ensuring the 'Official Show File' is a verifiable sequence of paid signatures. No payment, no cue—eliminating accidental triggers and ghost-running. Discipline: Theater & Live Performance (live direction). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'Stage Cues' into x402 calls, the app moves performance data from volatile local software to a metered, permanent record. It introduces a 'Pay-per-Cue' model for guest designers and automated licensing for choreography. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Ghostlight" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-actormint-ledger-15-x402 Title: StageGate · x402 Theme: Theater & Live Performance (theater) · performance NFTs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Unlock 'Performance Proofs' via micro-settlement. Audience members pay 0.01 USDC to mint a time-stamped, actor-verified digital relic of a specific scene or live improv moment. Actors receive immediate streaming revenue for every digital 'curtain call' recorded on the ledger. No gas, just signed HTS transfer authorization for every play-to-own interaction. Why Hedera: Traditional theatre merch fails to capture the ephemeral nature of live performance. By moving the primitive from 'free mint' to 'pay-per-moment,' we turn live acting into a metered digital commodity where the audience signals value in real-time. Market: TAM $2.1B — The global live theatre and ticketing secondary market. | SAM $450M — The digital memorabilia and performer-direct creator economy. | SOM $12M — Off-Broadway and fringe circuit actors utilizing mobile-first micropayments. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Unlock 'Performance Proofs' via micro-settlement. Audience members pay 0.01 USDC to mint a time-stamped, actor-verified digital relic of a specific scene or live improv moment. Actors receive immediate streaming revenue for every digital 'curtain call' recorded on the ledger. No gas, just signed HTS transfer authorization for every play-to-own interaction. Discipline: Theater & Live Performance (performance NFTs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional theatre merch fails to capture the ephemeral nature of live performance. By moving the primitive from 'free mint' to 'pay-per-moment,' we turn live acting into a metered digital commodity where the audience signals value in real-time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-setbuild-contracts-16-x402 Title: StageLedger · x402 Theme: Theater & Live Performance (theater) · production agreements Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: SetBuild settles production agreements via micropayment primitives. Each signature, amendment receipt, or 'strike' clearance is an x402-metered event. Producers pay 0.01 USDC to cryptographically lock a build schedule or release a milestone payment, ensuring the carpenter and the scenographer are instantly aligned via state-change receipts. No bulk escrow—just pay-per-milestone progress. Why Hedera: Theater production is a high-velocity environment of verbal handshakes; x402 formalizes these into micro-ledgered commitments. By moving from 'contracts' to 'metered milestones,' the financial risk is atomized to the individual task level. Market: TAM $440M — Global live performance production and event carpentry industries. | SAM $18M — The specialized scenic design and union-heavy labor market in the US and UK. | SOM $1.2M — Independent theater festivals (Fringe, Off-Off-Broadway) and university drama departments needing low-overhead labor auditing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StageLedger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT SetBuild settles production agreements via micropayment primitives. Each signature, amendment receipt, or 'strike' clearance is an x402-metered event. Producers pay 0.01 USDC to cryptographically lock a build schedule or release a milestone payment, ensuring the carpenter and the scenographer are instantly aligned via state-change receipts. No bulk escrow—just pay-per-milestone progress. Discipline: Theater & Live Performance (production agreements). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Theater production is a high-velocity environment of verbal handshakes; x402 formalizes these into micro-ledgered commitments. By moving from 'contracts' to 'metered milestones,' the financial risk is atomized to the individual task level. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StageLedger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-lightingtoken-rewards-17-x402 Title: LumenStream · x402 Theme: Theater & Live Performance (theater) · crew incentives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Convert live cues into immediate equity. Production designers commit USDC to a pool; every successful lighting cue or 'look' triggered during the show triggers a 0.01 USDC micro-payout to the operator's Magic Link email sign-in. It turns the lighting desk into a performance-mining rig. No more waiting for payroll to feel the impact of a flawless show. Why Hedera: Traditional crew bonuses are opaque and delayed. x402 enables 'Proof of Performance' where the physical act of running a show creates a real-time stream of micropayments, aligning technical excellence with instant financial settlement. Market: TAM $2.4B — The global theatrical production and live event technical labor market. | SAM $380M — The segment of the live events market utilizing digital lighting consoles and integrated production management. | SOM $12M — Touring Broadway productions and Tier-1 music festivals on-boarding crew via HashPack for automated cue-settlement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LumenStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Convert live cues into immediate equity. Production designers commit USDC to a pool; every successful lighting cue or 'look' triggered during the show triggers a 0.01 USDC micro-payout to the operator's Magic Link email sign-in. It turns the lighting desk into a performance-mining rig. No more waiting for payroll to feel the impact of a flawless show. Discipline: Theater & Live Performance (crew incentives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional crew bonuses are opaque and delayed. x402 enables 'Proof of Performance' where the physical act of running a show creates a real-time stream of micropayments, aligning technical excellence with instant financial settlement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LumenStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-playwright-guild-18-x402 Title: ScriptForce · x402 Theme: Theater & Live Performance (theater) · community membership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A distributed playwright's room where every plot suggestion, peer review, and script rewrite is a 0.01 USDC micro-transaction. Guild members sign with the embedded wallet to bypass gas, paying per interaction to fund a collective production treasury. Top-voted dialogue lines earn instant performance royalties on-chain. Why Hedera: Shifting from 'membership access' to 'metered contribution' turns a passive guild into a high-velocity production engine. HTS transfer allows for frictionless, sub-cent collaboration that builds a real-time budget for the final play. Market: TAM $2.1B — The global theatrical production and intellectual property licensing economy. | SAM $450M — The creative professional and self-publishing playwright market seeking alternative funding models. | SOM $12M — Web3-native dramatists and experimental theater labs on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScriptForce" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A distributed playwright's room where every plot suggestion, peer review, and script rewrite is a 0.01 USDC micro-transaction. Guild members sign with the embedded wallet to bypass gas, paying per interaction to fund a collective production treasury. Top-voted dialogue lines earn instant performance royalties on-chain. Discipline: Theater & Live Performance (community membership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from 'membership access' to 'metered contribution' turns a passive guild into a high-velocity production engine. HTS transfer allows for frictionless, sub-cent collaboration that builds a real-time budget for the final play. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScriptForce" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-audiencetip-jar-19-x402 Title: SPOTLIGHT · x402 Theme: Theater & Live Performance (theater) · fan monetization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Audience interactions as transaction primitives. Enforce pay-per-signal micro-payments where fans authorize 0.01 USDC per HTS transfer signature to trigger stage effects, vote on improvisational turns, or unlock exclusive backstage POV streams. No more 'donation' friction; every fan reaction is a sub-cent settlement on Hedera that provides the performer immediate, liquid programmatic revenue. Why Hedera: Shifts 'tipping' from a passive gesture to an active, low-friction participation layer. By using x402, the barrier to support is lowered to a single signature, enabling high-velocity micro-transactions during a live set that would be impossible with traditional gas-heavy flows. Market: TAM $45B — The global live performance and ticketed events industry, increasingly integrating digital-first interaction layers. | SAM $2.1B — The burgeoning market for immersive theater, 'creator missions', and NFT-gated fan experiences. | SOM $85M — On-chain live performers and hybrid virtual events leveraging HashPack-enabled web3 wallets for instant fan engagement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SPOTLIGHT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Audience interactions as transaction primitives. Enforce pay-per-signal micro-payments where fans authorize 0.01 USDC per HTS transfer signature to trigger stage effects, vote on improvisational turns, or unlock exclusive backstage POV streams. No more 'donation' friction; every fan reaction is a sub-cent settlement on Hedera that provides the performer immediate, liquid programmatic revenue. Discipline: Theater & Live Performance (fan monetization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts 'tipping' from a passive gesture to an active, low-friction participation layer. By using x402, the barrier to support is lowered to a single signature, enabling high-velocity micro-transactions during a live set that would be impossible with traditional gas-heavy flows. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SPOTLIGHT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-promochain-posters-20-x402 Title: BROADWAY · x402 Theme: Theater & Live Performance (theater) · marketing assets Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Posters shouldn't be free—they should be fuel. BROADWAY is a pay-per-impression marketing engine. Producers upload high-res motion posters; fans pay 0.01 USDC to 'Flash-Sync' the asset to their social feeds or local digital signage via the embedded wallet. Each 0.01 USDC pays for the hosting/CDN and a micro-royalty to the graphic artist. Instead of free NFTs that rot, it's a metered amplification network where influencers are paid by agents to host assets, and fans pay to own the high-fidelity moment. Why Hedera: Transitioning from 'free mints' to 'micro-metered access' ensures the asset has value. By using HTS transfer rituals, we remove the friction of 'purchasing' and turn it into a 'tap-to-display' behavior, turning every fan's screen into a paid ad-slot. Market: TAM $1.2B - The global theatrical advertising and promotional collateral market. | SAM $85M - Marketing spend for touring Broadway shows and indie theater circuits in the US. | SOM $4.2M - Initial capture of digital out-of-home (DOOH) micro-payments and fan-led social amplification for Off-Broadway launches. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "BROADWAY" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Posters shouldn't be free—they should be fuel. BROADWAY is a pay-per-impression marketing engine. Producers upload high-res motion posters; fans pay 0.01 USDC to 'Flash-Sync' the asset to their social feeds or local digital signage via the embedded wallet. Each 0.01 USDC pays for the hosting/CDN and a micro-royalty to the graphic artist. Instead of free NFTs that rot, it's a metered amplification network where influencers are paid by agents to host assets, and fans pay to own the high-fidelity moment. Discipline: Theater & Live Performance (marketing assets). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from 'free mints' to 'micro-metered access' ensures the asset has value. By using HTS transfer rituals, we remove the friction of 'purchasing' and turn it into a 'tap-to-display' behavior, turning every fan's screen into a paid ad-slot. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "BROADWAY" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-scriptarchive-dao-21-x402 Title: Prompter · x402 Theme: Theater & Live Performance (theater) · preservation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-scene digital library for playwrights and dramaturges. Users pay $0.01 USDC to unlock an archival script or view production notes. x402 handles the micro-royalty flow directly to the original rights holder with every read, enabling a self-sustaining archive where historical preservation is funded by active consumption rather than static grants. Why Hedera: Replacing the DAO governance model with a direct pay-per-view primitive simplifies the incentive structure. Instead of voting on 'curation,' the market curates through micropayments, ensuring the most valuable scripts are preserved by the revenue they generate per call. Market: TAM $2.1B — The global intellectual property and theatrical publishing market transitioning to digital-first distribution. | SAM $125M — Digital licensing and academic script access market. | SOM $850K — Independent playwrights and fringe theater archives on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Prompter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-scene digital library for playwrights and dramaturges. Users pay $0.01 USDC to unlock an archival script or view production notes. x402 handles the micro-royalty flow directly to the original rights holder with every read, enabling a self-sustaining archive where historical preservation is funded by active consumption rather than static grants. Discipline: Theater & Live Performance (preservation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Replacing the DAO governance model with a direct pay-per-view primitive simplifies the incentive structure. Instead of voting on 'curation,' the market curates through micropayments, ensuring the most valuable scripts are preserved by the revenue they generate per call. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Prompter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-virtualstage-access-22-x402 Title: Proscenium · x402 Theme: Theater & Live Performance (theater) · hybrid performances Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Access ultra-low latency hybrid performance streams via HTS transfer. Every 'scene' or 'camera angle' is a metered micro-settlement. Eliminate bulk ticket friction: users pay 1 cent per minute of engagement or per interactive stage trigger. Actors receive instant USDC settlement as the audience interacts with the virtual set in real-time. Why Hedera: Transitions from 'sponsored/free' to a value-for-value micro-duration model. Using x402 allows for granular 'pay-as-you-watch' or 'pay-to-influence' mechanics that traditional ticketing systems cannot handle due to transaction costs. Market: TAM $9.2B — The global hybrid events and virtual performance market. | SAM $150M — The emerging market for decentralized ticketing and interactive livestreaming infrastructure. | SOM $12M — On-chain hybrid theater enthusiasts and experimental digital performance art collectives on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proscenium" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Access ultra-low latency hybrid performance streams via HTS transfer. Every 'scene' or 'camera angle' is a metered micro-settlement. Eliminate bulk ticket friction: users pay 1 cent per minute of engagement or per interactive stage trigger. Actors receive instant USDC settlement as the audience interacts with the virtual set in real-time. Discipline: Theater & Live Performance (hybrid performances). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitions from 'sponsored/free' to a value-for-value micro-duration model. Using x402 allows for granular 'pay-as-you-watch' or 'pay-to-influence' mechanics that traditional ticketing systems cannot handle due to transaction costs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proscenium" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-critiquechain-feedback-23-x402 Title: Ovation · x402 Theme: Theater & Live Performance (theater) · performance reviews Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A performance review engine where words carry weight because they cost. Audience members pay 0.01 USDC to mint a 'Verified Critique' that settles instantly to the performer's wallet. Performers can gate their backstage content or rehearsals, requiring a micropayment to view or 'stage-door' digital access via HTS transfer. Turn passive applause into a metered, programmable revenue stream for the arts. Why Hedera: By moving from 'no-fee' to 'micro-fee', we solve the Sybil-review problem. A critique that costs 0.01 USDC is more reliable than a free one. It transforms the relationship between the critic and the artist into a direct, friction-minimized financial settlement. Market: TAM $4.2B — The total addressable market for verified experiential feedback and creator-direct micropayments across all live entertainment venues. | SAM $850M — The global digital ticketing and fan engagement market for independent theater and live performing arts. | SOM $12M — Early adopters in the fringe theater and experimental performance space using Base for audience monetization. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Ovation" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A performance review engine where words carry weight because they cost. Audience members pay 0.01 USDC to mint a 'Verified Critique' that settles instantly to the performer's wallet. Performers can gate their backstage content or rehearsals, requiring a micropayment to view or 'stage-door' digital access via HTS transfer. Turn passive applause into a metered, programmable revenue stream for the arts. Discipline: Theater & Live Performance (performance reviews). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'no-fee' to 'micro-fee', we solve the Sybil-review problem. A critique that costs 0.01 USDC is more reliable than a free one. It transforms the relationship between the critic and the artist into a direct, friction-minimized financial settlement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Ovation" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-setinventory-nfts-24-x402 Title: PROPFLOW · x402 Theme: Theater & Live Performance (theater) · asset tokenization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-velocity rental protocol for props and costumes. Instead of manual contracts, theatrical houses use x402 to 'unlock' assets. Pay $0.05 USDC to ping availability, $1.00 USDC to secure a hold, and recurring micropayments for automated rental extensions. Every status change—from 'in storage' to 'on stage'—is a settled transaction triggered by a signed Magic Link email sign-in at the stage door. Why Hedera: Legacy rental systems are bogged down by administrative overhead. By moving to pay-per-event logic, theaters can monetize idle inventory with zero friction, allowing small troupes to rent pro-grade sets via automated 'pay-to-reserve' micropayments. Market: TAM $4.2B — The global theatrical production and live event equipment supply market. | SAM $850M — The shared economy layer for regional theaters, universities, and high school drama departments. | SOM $12M — The rental and inventory management fees within the NYC/Off-Broadway production circuit. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PROPFLOW" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-velocity rental protocol for props and costumes. Instead of manual contracts, theatrical houses use x402 to 'unlock' assets. Pay $0.05 USDC to ping availability, $1.00 USDC to secure a hold, and recurring micropayments for automated rental extensions. Every status change—from 'in storage' to 'on stage'—is a settled transaction triggered by a signed Magic Link email sign-in at the stage door. Discipline: Theater & Live Performance (asset tokenization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Legacy rental systems are bogged down by administrative overhead. By moving to pay-per-event logic, theaters can monetize idle inventory with zero friction, allowing small troupes to rent pro-grade sets via automated 'pay-to-reserve' micropayments. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PROPFLOW" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-scene-script-provenance-0-x402 Title: ScriptFlow · x402 Theme: Theater & Live Performance (theater) · playwriting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An encrypted playwriting repository where every scene export or script 'read-through' trigger incurs a 0.01 USDC fee paid directly to the playwright. Instead of static minting, it meters access for actors, directors, and AI casting agents, settling usage royalties in real-time. Why Hedera: Shifts playwriting from a one-time static NFT sale to a metered utility where collaborators pay per script iteration accessed, ensuring the writer is compensated for every stage of the rehearsal process. Market: TAM $4.2B — Global theatrical publishing, licensing rights, and script services. | SAM $280M — Digital licensing and script distribution for professional regional theaters and touring companies. | SOM $12M — Independent playwrights and fringe festivals adopting pay-per-read rehearsal models. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScriptFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An encrypted playwriting repository where every scene export or script 'read-through' trigger incurs a 0.01 USDC fee paid directly to the playwright. Instead of static minting, it meters access for actors, directors, and AI casting agents, settling usage royalties in real-time. Discipline: Theater & Live Performance (playwriting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts playwriting from a one-time static NFT sale to a metered utility where collaborators pay per script iteration accessed, ensuring the writer is compensated for every stage of the rehearsal process. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScriptFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-lighting-cue-chain-1-x402 Title: LUMEN · x402 Theme: Theater & Live Performance (theater) · lighting design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-trigger lighting protocol where LDs license 'Cue Stacks' to venues. Instead of a one-time fee, the console pays 0.01 USDC via HTS transfer for every lighting transition executed. A cryptographic 'Proof-of-Cue' is posted to Base, ensuring designers are paid for every single performance, rehearsal, or touring stop without manual tracking. Why Hedera: Lighting design is often 'stolen' or reused without credit in touring. Moving from a flat fee to a metered per-cue model ensures the designer's IP is continuously monetized as the show runs. Market: TAM $2.1B — The global stage lighting and concert production hardware-software market. | SAM $450M — Revenue from touring tech services and theatrical licensing software. | SOM $12M — Independent lighting designers and regional touring houses on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LUMEN" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-trigger lighting protocol where LDs license 'Cue Stacks' to venues. Instead of a one-time fee, the console pays 0.01 USDC via HTS transfer for every lighting transition executed. A cryptographic 'Proof-of-Cue' is posted to Base, ensuring designers are paid for every single performance, rehearsal, or touring stop without manual tracking. Discipline: Theater & Live Performance (lighting design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Lighting design is often 'stolen' or reused without credit in touring. Moving from a flat fee to a metered per-cue model ensures the designer's IP is continuously monetized as the show runs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LUMEN" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-actor-rehearsal-logs-2-x402 Title: REHEARSE · x402 Theme: Theater & Live Performance (theater) · performance tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A proof-of-practice engine where actors sign one-time 0.01 USDC x402 permits to commit rehearsal timestamps to the ledger. Directors and casting agents pay per 'Review' to unlock specific performance logs, creating a verifiable, paid audit trail of an actor's preparation journey. 0.01 USDC to log, 0.05 USDC to review. Why Hedera: Shifts the focus from static NFTs to a metered 'honesty protocol.' The micropayment creates a financial commitment to the craft, while the pay-per-view model for directors ensures actors are compensated for the 'behind-the-scenes' labor of preparation. Market: TAM $2.4B — The worldwide live performance and talent discovery industry shifting to verifiable digital resumes. | SAM $180M — The global production casting and performance management market adopting transparent logging. | SOM $12M — Indie theater productions and performing arts academies using automated logs for grading and casting. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "REHEARSE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A proof-of-practice engine where actors sign one-time 0.01 USDC x402 permits to commit rehearsal timestamps to the ledger. Directors and casting agents pay per 'Review' to unlock specific performance logs, creating a verifiable, paid audit trail of an actor's preparation journey. 0.01 USDC to log, 0.05 USDC to review. Discipline: Theater & Live Performance (performance tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the focus from static NFTs to a metered 'honesty protocol.' The micropayment creates a financial commitment to the craft, while the pay-per-view model for directors ensures actors are compensated for the 'behind-the-scenes' labor of preparation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "REHEARSE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-set-design-archives-3-x402 Title: Proscenium · x402 Theme: Theater & Live Performance (theater) · scenic design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity digital vault for scenic blueprints. Pay 0.01 USDC to unlock an immersive 3D walkthrough or download a high-res CAD model. Designers earn at the point of inspection, ensuring every reference to their spatial IP is a paid engagement. Why Hedera: Traditional NFT minting is too heavy for iterative design reviews. x402 enables a 'pay-per-view' model for stage directions and asset blueprints, turning a historical archive into a streaming revenue engine for designers. Market: TAM $1.2B — The total global expenditure on theatrical production and IP management. | SAM $85M — Digital assets and visualization software for the global professional theater market. | SOM $4.2M — Independent scenic designers and collegiate theater programs requiring secure, metered asset sharing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proscenium" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity digital vault for scenic blueprints. Pay 0.01 USDC to unlock an immersive 3D walkthrough or download a high-res CAD model. Designers earn at the point of inspection, ensuring every reference to their spatial IP is a paid engagement. Discipline: Theater & Live Performance (scenic design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT minting is too heavy for iterative design reviews. x402 enables a 'pay-per-view' model for stage directions and asset blueprints, turning a historical archive into a streaming revenue engine for designers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proscenium" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-playwright-feedback-chain-4-x402 Title: SCRIPTBLOCK · x402 Theme: Theater & Live Performance (theater) · script development Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized dramaturgy engine where every script critique, margin note, and revision is metered at 0.01 USDC. Playwrights pay to unlock high-signal feedback from a verified peer network, while reviewers earn instantly per 'Redline' submitted. The x402 primitive creates a financial incentive for deep-tissue editing that standard NFT version control lacks—turning the 'Chain' into a live, paid micro-economy of craft. Why Hedera: By moving from static NFTs to x402 micropayments, we solve the 'passive reviewer' problem. Payment per feedback-call ensures participants are compensated for the labor of reading, while the cost-per-unlock prevents spam and noise in the revision history. Market: TAM $1.4B — The global creative writing and script development software market, increasingly dominated by collaborative cloud tools. | SAM $85M — Professional playwrights, script consultants, and dramatic writing MFA programs globally. | SOM $4.2M — Emerging writers using Base for low-gas intellectual property management and peer-review loops. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SCRIPTBLOCK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized dramaturgy engine where every script critique, margin note, and revision is metered at 0.01 USDC. Playwrights pay to unlock high-signal feedback from a verified peer network, while reviewers earn instantly per 'Redline' submitted. The x402 primitive creates a financial incentive for deep-tissue editing that standard NFT version control lacks—turning the 'Chain' into a live, paid micro-economy of craft. Discipline: Theater & Live Performance (script development). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from static NFTs to x402 micropayments, we solve the 'passive reviewer' problem. Payment per feedback-call ensures participants are compensated for the labor of reading, while the cost-per-unlock prevents spam and noise in the revision history. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SCRIPTBLOCK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-performance-nft-tickets-5-x402 Title: Applause · x402 Theme: Theater & Live Performance (theater) · audience engagement Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time 'Digital Standing Ovation' layer for live performances. Audience members use the app to trigger haptic feedback, light arrays, or soundscapes within the venue during key moments. Each interaction costs 0.01 USDC, creating a live heat-map of engagement that stays recorded on-chain as a fractionalized 'memory' of the performance. Participation mints a dynamic attendance badge post-show. Why Hedera: Moves from static ticketing to active, metered participation. Frictionless micropayments turn passive viewing into a micro-tipping mechanic for the performers. Market: TAM $35B — Global ticketing and live event engagement market moving toward verifiable digital interaction. | SAM $450M — On-chain live event spend and digital collectible volume for performance arts. | SOM $12M — Early adopters in immersive theater and experimental music festivals on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Applause" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time 'Digital Standing Ovation' layer for live performances. Audience members use the app to trigger haptic feedback, light arrays, or soundscapes within the venue during key moments. Each interaction costs 0.01 USDC, creating a live heat-map of engagement that stays recorded on-chain as a fractionalized 'memory' of the performance. Participation mints a dynamic attendance badge post-show. Discipline: Theater & Live Performance (audience engagement). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves from static ticketing to active, metered participation. Frictionless micropayments turn passive viewing into a micro-tipping mechanic for the performers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Applause" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-costume-provenance-chain-6-x402 Title: STITCH · x402 Theme: Theater & Live Performance (theater) · costume design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographic ledger for high-fashion and theatrical hardware. Pay 0.01 USDC to verify a garment's production lineage, authorize a rental usage rights transfer, or unlock high-resolution technical patterns. Every stitch, alteration, and stage appearance is a billable event, ensuring designers earn a micropayment every time a costume is checked out of the wardrobe or referenced for a new production. Why Hedera: By shifting from a static NFT to a pay-per-access provenance model, designers capture value from the high-frequency lifecycle of costumes (rentals, fittings, and visual audits) rather than just a one-time sale. Market: TAM $4.2B — The global entertainment asset management and intellectual property licensing market. | SAM $850M — The global theatrical costume rental and design market transitioning to digital rights management. | SOM $12M — Independent costume houses and regional theaters adopting automated, friction-less inventory micro-billing on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STITCH" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographic ledger for high-fashion and theatrical hardware. Pay 0.01 USDC to verify a garment's production lineage, authorize a rental usage rights transfer, or unlock high-resolution technical patterns. Every stitch, alteration, and stage appearance is a billable event, ensuring designers earn a micropayment every time a costume is checked out of the wardrobe or referenced for a new production. Discipline: Theater & Live Performance (costume design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a static NFT to a pay-per-access provenance model, designers capture value from the high-frequency lifecycle of costumes (rentals, fittings, and visual audits) rather than just a one-time sale. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STITCH" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-monologue-minting-hub-7-x402 Title: STAGED · x402 Theme: Theater & Live Performance (theater) · acting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered audition engine where casting directors pay $0.01 USDC to stream-unlock exclusive 60-second performances. Actors set their 'Call Time' rate, and x402 handles the per-view micro-settlement, ensuring performers are paid for every 'digital open call' without subscription friction. Why Hedera: Traditional monetization for actors is binary (hired/unhired). This shifts the value to the 'audition data' itself. Casting agents micropay to browse, and actors receive instant liquidity for their labor, turning the Monologue Hub into a high-frequency per-view marketplace rather than a static gallery. Market: TAM $1.2B — The global talent acquisition and performer management software market. | SAM $85M — Digital audition fees, self-tape services, and professional acting coach subscriptions. | SOM $4.2M — Micro-transaction volume for high-velocity casting calls and independent film talent scouting on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STAGED" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered audition engine where casting directors pay $0.01 USDC to stream-unlock exclusive 60-second performances. Actors set their 'Call Time' rate, and x402 handles the per-view micro-settlement, ensuring performers are paid for every 'digital open call' without subscription friction. Discipline: Theater & Live Performance (acting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional monetization for actors is binary (hired/unhired). This shifts the value to the 'audition data' itself. Casting agents micropay to browse, and actors receive instant liquidity for their labor, turning the Monologue Hub into a high-frequency per-view marketplace rather than a static gallery. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STAGED" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-soundscape-provenance-8-x402 Title: EchoPass · x402 Theme: Theater & Live Performance (theater) · sound design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time soundscape metering protocol for live performance. Audio engineers use an x402-enabled DAW plugin to 'check out' high-fidelity environmental presets. Each time a specific atmospheric trigger is fired during a show (100ms of grain-delay, a specific synth patch, or a spatialized reverb tail), a 0.01 USDC event is signed by the sound board's Magic Link email sign-in. This facilitates per-staged-use revenue for sound designers, replacing flat-fee licensing with live usage settlement. Audience members can pay per-stroll to 'Unlock Live Stem' via QR to listen to isolated audio layers on their devices. Why Hedera: Theater designers are often underpaid for 'background' assets. By moving from static NFT minting to a pay-per-trigger model, the sound designer transitions from a one-time seller to a micro-royalty participant in every night's performance. The HTS transfer flow ensures the board operator doesn't need to manually sign transactions during a live cue sequence. Market: TAM $3.2B — Global theatrical production, live event sound design, and themed entertainment audio integration. | SAM $450M — The touring performance market and immersive theater sound licensing (Off-Broadway, Vegas Residencies, West End). | SOM $12M — Independent fringe festival creators and immersive 'sleep no more' style audio-first experiences. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "EchoPass" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time soundscape metering protocol for live performance. Audio engineers use an x402-enabled DAW plugin to 'check out' high-fidelity environmental presets. Each time a specific atmospheric trigger is fired during a show (100ms of grain-delay, a specific synth patch, or a spatialized reverb tail), a 0.01 USDC event is signed by the sound board's Magic Link email sign-in. This facilitates per-staged-use revenue for sound designers, replacing flat-fee licensing with live usage settlement. Audience members can pay per-stroll to 'Unlock Live Stem' via QR to listen to isolated audio layers on their devices. Discipline: Theater & Live Performance (sound design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Theater designers are often underpaid for 'background' assets. By moving from static NFT minting to a pay-per-trigger model, the sound designer transitions from a one-time seller to a micro-royalty participant in every night's performance. The HTS transfer flow ensures the board operator doesn't need to manually sign transactions during a live cue sequence. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "EchoPass" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-director-s-vision-ledger-9-x402 Title: Vision Ledger · x402 Theme: Theater & Live Performance (theater) · direction Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn the rehearsal room into a high-fidelity data stream. Sub-cent micropayments meter the access to the 'Master Script'—a real-time evolving document of blocking, subtext, and lighting cues. Performers pay to sync latest cues; researchers pay per node to audit creative choices. The director is compensated for every look, cross, and beat change recorded. Why Hedera: Shifts the asset from a static NFT into a dynamic, metered stream of professional IP where each 'look' or 'blocking note' is a billable micro-event. Market: TAM $2.8B — Global performing arts IP, including licensing, pedagogical archives, and digital twins of stage productions. | SAM $140M — Professional theater practitioners, academics, and regional houses requiring precise vision-syncing. | SOM $12M — Off-Broadway and indie directors using Base to track and monetize rehearsal IP. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vision Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn the rehearsal room into a high-fidelity data stream. Sub-cent micropayments meter the access to the 'Master Script'—a real-time evolving document of blocking, subtext, and lighting cues. Performers pay to sync latest cues; researchers pay per node to audit creative choices. The director is compensated for every look, cross, and beat change recorded. Discipline: Theater & Live Performance (direction). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the asset from a static NFT into a dynamic, metered stream of professional IP where each 'look' or 'blocking note' is a billable micro-event. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vision Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-interactive-stage-nfts-10-x402 Title: CUE · x402 Theme: Theater & Live Performance (theater) · stage interaction Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time 'Pay-for-Play' stage trigger system. Audience members sign HTS transfer permits via the embedded wallet to trigger physical or digital effects (lighting cues, soundscapes, or prop releases) live. Every interaction is a 0.01 USDC micro-tx that settles instantly on Hedera, giving the performer creative agency and the audience direct tactile influence over the show. Why Hedera: Moving away from 'ownership' NFTs toward 'interaction' utility. The x402 protocol turns the audience from passive observers into micro-funders of specific stage actions. Market: TAM $48B — The global live events and performance art market shifting toward digital-physical convergence. | SAM $120M — The tech-enabled live theater and immersive experience market globally. | SOM $8M — Immersive fringe festivals and tech-forward Off-Broadway venues utilizing audience-interactive software. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CUE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time 'Pay-for-Play' stage trigger system. Audience members sign HTS transfer permits via the embedded wallet to trigger physical or digital effects (lighting cues, soundscapes, or prop releases) live. Every interaction is a 0.01 USDC micro-tx that settles instantly on Hedera, giving the performer creative agency and the audience direct tactile influence over the show. Discipline: Theater & Live Performance (stage interaction). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving away from 'ownership' NFTs toward 'interaction' utility. The x402 protocol turns the audience from passive observers into micro-funders of specific stage actions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CUE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-improv-moment-mint-11-x402 Title: SCENE SHOT · x402 Theme: Theater & Live Performance (theater) · improvisation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time prompt-injection engine for live improv. Audience members pay 0.01 USDC to beam a mandatory plot twist, object, or emotion directly to the performer's AR HUD or stage monitor. Performers get paid instantly per 'challenge' accepted, turning the stage into a live-metered feedback loop where the script is literally bought mid-sentence. Why Hedera: Traditional improv relies on shouted suggestions that are often ignored or unheard. x402 turns audience participation into a verifiable economic primitive, ensuring every 'suggestion' is a paid micro-contract that the performer is incentivized to satisfy in real-time. Market: TAM $2.1B — The global 'Live Entertainment Experience' market shifting toward interactive, gamified performance. | SAM $450M — The global experimental theater and fringe festival circuit adopting digital audience-participation tools. | SOM $12M — Professional improv troupes (UCB, Second City) and high-tier Twitch roleplay streamers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SCENE SHOT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time prompt-injection engine for live improv. Audience members pay 0.01 USDC to beam a mandatory plot twist, object, or emotion directly to the performer's AR HUD or stage monitor. Performers get paid instantly per 'challenge' accepted, turning the stage into a live-metered feedback loop where the script is literally bought mid-sentence. Discipline: Theater & Live Performance (improvisation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional improv relies on shouted suggestions that are often ignored or unheard. x402 turns audience participation into a verifiable economic primitive, ensuring every 'suggestion' is a paid micro-contract that the performer is incentivized to satisfy in real-time. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SCENE SHOT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-choreography-chain-12-x402 Title: KINETIC · x402 Theme: Theater & Live Performance (theater) · movement design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A movement-primitive library where choreographers monetize specific sequences. Instead of clunky NFT mints, users pay $0.01 per pose/transition to access high-res motion data or AR overlays. Every time a dance troupe or solo artist 'calls' a sequence for rehearsal or digital capture, the creator is settled instantly. Movement as an executable script. Why Hedera: Current choreography rights are unenforceable. By turning movement into granular, pay-per-use data calls, creators get paid for the actual utility of their work in rehearsals and social media production, rather than speculative static assets. Market: TAM $4.2B — The global dance education and live theatrical performance industry transitioning to digital workflows. | SAM $280M — The digital performance rights and licensed movement market for theater, film, and social content. | SOM $12M — Independent movement designers and dance influencers using automated micro-licensing on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A movement-primitive library where choreographers monetize specific sequences. Instead of clunky NFT mints, users pay $0.01 per pose/transition to access high-res motion data or AR overlays. Every time a dance troupe or solo artist 'calls' a sequence for rehearsal or digital capture, the creator is settled instantly. Movement as an executable script. Discipline: Theater & Live Performance (movement design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current choreography rights are unenforceable. By turning movement into granular, pay-per-use data calls, creators get paid for the actual utility of their work in rehearsals and social media production, rather than speculative static assets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-stage-props-provenance-13-x402 Title: Backstage Pulse · x402 Theme: Theater & Live Performance (theater) · prop management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time ledger for professional prop masters. Pay 0.01 USDC to instantly log a location change, verify safety inspection status, or authorize a cross-production rental. Every handover is a micro-transactional handshake, ensuring the physical prop's history is immutably anchored on-chain without the friction of traditional gas. Why Hedera: By moving from static NFT minting to a pay-per-event stream, the app becomes an active workflow tool. Prop houses charge small fees for status inquiries or transfer signatures, creating a high-velocity micro-economy for high-value physical assets. Market: TAM $4.2B — Global stagecraft, costume, and hardware management systems. | SAM $850M — The equipment and asset rental market for theater, film, and live events. | SOM $12M — Regional theater prop houses and touring companies adopting Base-native micro-asset tracking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Backstage Pulse" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time ledger for professional prop masters. Pay 0.01 USDC to instantly log a location change, verify safety inspection status, or authorize a cross-production rental. Every handover is a micro-transactional handshake, ensuring the physical prop's history is immutably anchored on-chain without the friction of traditional gas. Discipline: Theater & Live Performance (prop management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from static NFT minting to a pay-per-event stream, the app becomes an active workflow tool. Prop houses charge small fees for status inquiries or transfer signatures, creating a high-velocity micro-economy for high-value physical assets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Backstage Pulse" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-script-translation-nfts-14-x402 Title: Polyglot Stage · x402 Theme: Theater & Live Performance (theater) · translation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Translators gate their work behind x402 micropayments. Actors and directors pay 0.01 USDC to unlock an AI-assisted, context-aware performance translation or a single page of localized dialogue. Every 'read' is a direct settlement to the linguist, turning static scripts into metered performance assets. Why Hedera: By shifting from NFT ownership to per-page/per-access micropayments, we solve the liquidity issue for niche play scripts. Performers pay only for the scenes they rehearse, and translators earn ongoing revenue from every rehearsal session rather than a one-time sale. Market: TAM $8.5B — Global performance rights and script distribution market. | SAM $1.2B — Professional theater translation and localization services globally. | SOM $15M — Bilingual theater troupes and independent translators on Hedera utilizing automated licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Polyglot Stage" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Translators gate their work behind x402 micropayments. Actors and directors pay 0.01 USDC to unlock an AI-assisted, context-aware performance translation or a single page of localized dialogue. Every 'read' is a direct settlement to the linguist, turning static scripts into metered performance assets. Discipline: Theater & Live Performance (translation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from NFT ownership to per-page/per-access micropayments, we solve the liquidity issue for niche play scripts. Performers pay only for the scenes they rehearse, and translators earn ongoing revenue from every rehearsal session rather than a one-time sale. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Polyglot Stage" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-audience-experience-logs-15-x402 Title: HEARTBEAT · x402 Theme: Theater & Live Performance (theater) · audience analytics Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time sentiment engine for live theater. Audience members sign a 0.01 USDC x402 authorization to unlock a 'Reaction Stream'—the app uses their device's mic/camera to log biometric engagement peaks and laughter frequency. Data is sold back to the production company in aggregate, with all participants paid a micro-rebate per data-point settled via Hedera transaction id. Why Hedera: Micropayments flip the data model: instead of the audience being tracked for free, the venue pays a metered rate (0.01 USDC/event) to access the 'Reaction Log' of individual seats, turning every gasp into a micro-settlement. Market: TAM $2.8B — Global live performance and event analytics market transitioning to participatory data models. | SAM $420M — Professional theater companies and touring Broadway productions utilizing digital sentiment tracking. | SOM $18.5M — Experimental tech-heavy performance venues in NYC, London, and Berlin. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "HEARTBEAT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time sentiment engine for live theater. Audience members sign a 0.01 USDC x402 authorization to unlock a 'Reaction Stream'—the app uses their device's mic/camera to log biometric engagement peaks and laughter frequency. Data is sold back to the production company in aggregate, with all participants paid a micro-rebate per data-point settled via Hedera transaction id. Discipline: Theater & Live Performance (audience analytics). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Micropayments flip the data model: instead of the audience being tracked for free, the venue pays a metered rate (0.01 USDC/event) to access the 'Reaction Log' of individual seats, turning every gasp into a micro-settlement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "HEARTBEAT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-virtual-backstage-pass-16-x402 Title: BACKSTAGE PROXY · x402 Theme: Theater & Live Performance (theater) · fan engagement Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A streaming metadata layer that meters stage-door access. Fans pay 0.01 USDC per minute of 'Backstage Audio' or per 'POV Sightline' switch during live performances. Each interaction triggers an HTS transfer signature, settling instantly from a the embedded wallet-embedded wallet to the cast's multi-sig. No subscriptions, just pay-per-glimpse. Why Hedera: Moving from static NFT passes to x402-native micro-metering turns 'access' into a granular, high-frequency revenue stream that allows fans to pay only for the moments they are engaged. Market: TAM $12.5B — The global theatre and live performing arts market shifting toward hybrid participation. | SAM $480M — Global live performance digital engagement and premium streaming add-ons. | SOM $12M — Micro-transaction volume for Broadway and West End experimental digital programs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "BACKSTAGE PROXY" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A streaming metadata layer that meters stage-door access. Fans pay 0.01 USDC per minute of 'Backstage Audio' or per 'POV Sightline' switch during live performances. Each interaction triggers an HTS transfer signature, settling instantly from a the embedded wallet-embedded wallet to the cast's multi-sig. No subscriptions, just pay-per-glimpse. Discipline: Theater & Live Performance (fan engagement). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from static NFT passes to x402-native micro-metering turns 'access' into a granular, high-frequency revenue stream that allows fans to pay only for the moments they are engaged. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "BACKSTAGE PROXY" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-playbill-provenance-17-x402 Title: Curtain Call · x402 Theme: Theater & Live Performance (theater) · program design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time, high-fidelity archive for live performance. Pay 0.05 USDC per 'Curtain Call' to unlock an exclusive, cryptographically-signed digital program for the specific performance you attended. Includes the night's specific cast-substitution list, conductor notes, and high-res production stills available only during the 24-hour window post-show. Payments go directly to the production's royalty pool. Why Hedera: Moving from a static NFT mint to a time-sensitive, pay-per-access utility makes the program a live premium artifact rather than a dormant collectible. The x402 model enables micrometers for 'Deep Program' access (e.g., 0.01 USDC to view the director's script notes). Market: TAM $2.4B — The global theatre and performing arts ticketing and memorabilia market. | SAM $280M — Digital engagement and merchandise revenue for major theatrical hubs (Broadway, West End, Seoul). | SOM $12M — Early adopters in experimental theater and flagship residency programs seeking new monetization tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Curtain Call" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time, high-fidelity archive for live performance. Pay 0.05 USDC per 'Curtain Call' to unlock an exclusive, cryptographically-signed digital program for the specific performance you attended. Includes the night's specific cast-substitution list, conductor notes, and high-res production stills available only during the 24-hour window post-show. Payments go directly to the production's royalty pool. Discipline: Theater & Live Performance (program design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a static NFT mint to a time-sensitive, pay-per-access utility makes the program a live premium artifact rather than a dormant collectible. The x402 model enables micrometers for 'Deep Program' access (e.g., 0.01 USDC to view the director's script notes). 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Curtain Call" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-set-lighting-automation-nfts-18-x402 Title: LUXNODE · x402 Theme: Theater & Live Performance (theater) · lighting tech Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-cue. A protocol where stage lighting consoles (MA3, Hog) query a library of professional lighting sequences. Lighting designers receive USDC for every triggered sub-master or macro executed in a live environment. Eliminate bulky licensing; pay only for the scenes you fire during the show. Why Hedera: By moving from 'ownership' to 'metered execution,' lighting designers earn continuous royalties whenever their presets are used in touring productions, while venues avoid expensive one-time software buyouts for static cues. Market: TAM $2.4B — The global live entertainment production and stage automation market transitioning to decentralized asset libraries. | SAM $120M — Professional touring and regional theater lighting departments adopting HTS transfer protocols. | SOM $8M — Mid-sized EDM venues and fringe festivals integrating Hedera testnet automation for generative visuals. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LUXNODE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-cue. A protocol where stage lighting consoles (MA3, Hog) query a library of professional lighting sequences. Lighting designers receive USDC for every triggered sub-master or macro executed in a live environment. Eliminate bulky licensing; pay only for the scenes you fire during the show. Discipline: Theater & Live Performance (lighting tech). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'ownership' to 'metered execution,' lighting designers earn continuous royalties whenever their presets are used in touring productions, while venues avoid expensive one-time software buyouts for static cues. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LUXNODE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-drama-therapy-journals-19-x402 Title: Catharsis · x402 Theme: Theater & Live Performance (theater) · therapeutic performance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-act therapeutic journaling where users sign 0.01 USDC payloads to unlock generative role-play scenarios. Each 'scene' or journal entry is settled on Hedera, creating a private, immutable trail of performance milestones. Secure your healing through a pay-as-you-process model that ensures your intimate data is never sold, only accessed by you via HTS transfer auth. Why Hedera: Transitions from a static NFT mint to a dynamic, gated therapeutic process. $0.01 per entry enables frequent, low-friction micro-sessions that prioritize the privacy of the 'performance' over the resale value of an asset. Market: TAM $12.5B — Global tele-therapy and digital mental health market expanding into sovereign patient-owned data. | SAM $115M — Estimated segment of the digital mental health market specifically using drama and expressive arts therapy. | SOM $4.2M — Targeted at self-directed drama therapy practitioners and holistic wellness apps integrating on-chain identity via HashPack. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Catharsis" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-act therapeutic journaling where users sign 0.01 USDC payloads to unlock generative role-play scenarios. Each 'scene' or journal entry is settled on Hedera, creating a private, immutable trail of performance milestones. Secure your healing through a pay-as-you-process model that ensures your intimate data is never sold, only accessed by you via HTS transfer auth. Discipline: Theater & Live Performance (therapeutic performance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitions from a static NFT mint to a dynamic, gated therapeutic process. $0.01 per entry enables frequent, low-friction micro-sessions that prioritize the privacy of the 'performance' over the resale value of an asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Catharsis" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-costume-rental-nft-20-x402 Title: Wardrobe Protocol · x402 Theme: Theater & Live Performance (theater) · rental services Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Unlock digital wardrobe access. Move beyond clunky rental contracts by metering the 'Look.' Performers and stylists pay 0.01 USDC to unlock high-fidelity 3D costume specs, sizing metadata, and availability windows. Each HTS transfer signature facilitates a micro-lease, allowing independent theaters to pay-per-day for specific garment patterns or virtual try-on rights. No more flat-fee gatekeeping; pay only for the time you're on stage. Why Hedera: Shifts the model from a static NFT ownership play to a liquid, usage-based utility. By metering access to rental data and digital twins, it turns physical inventory into a programmable, pay-per-use asset class suitable for the high-frequency needs of touring companies. Market: TAM $2.8B — Global theatrical costume rental and wardrobe management market transitioning to digital verification. | SAM $120M — Regional theaters, independent wardrobe stylists, and 'pro-sumer' content creators requiring verified high-end costumes. | SOM $4.5M — Off-Broadway productions and tech-forward theater cooperatives on Hedera seeking to digitize shared inventory. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Wardrobe Protocol" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Unlock digital wardrobe access. Move beyond clunky rental contracts by metering the 'Look.' Performers and stylists pay 0.01 USDC to unlock high-fidelity 3D costume specs, sizing metadata, and availability windows. Each HTS transfer signature facilitates a micro-lease, allowing independent theaters to pay-per-day for specific garment patterns or virtual try-on rights. No more flat-fee gatekeeping; pay only for the time you're on stage. Discipline: Theater & Live Performance (rental services). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from a static NFT ownership play to a liquid, usage-based utility. By metering access to rental data and digital twins, it turns physical inventory into a programmable, pay-per-use asset class suitable for the high-frequency needs of touring companies. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Wardrobe Protocol" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-playwright-royalty-nfts-21-x402 Title: Proscenium · x402 Theme: Theater & Live Performance (theater) · royalty management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized script-clearing protocol where every page turn, digital rehearsal, or performance reading triggers an instant $0.01 royalty distribution. Instead of lumpy, delayed annual checks, the x402 primitive enables 'metered licensing': theater troupes pay per act performed, with USDC streamed instantly to the playwright and estate via HTS transfer. Payment isn't just a fee; it is the verifiable permission to perform. Why Hedera: Legacy royalty tracking is opaque and slow. x402 turns a script into a living asset where micro-usage (reading a scene) scales to macro-licensing (performing a show) through the same granular payment rail. Market: TAM $2.8B — The global theatrical publishing and licensing market (e.g., MTI, Concord Theatricals). | SAM $450M — The independent theater, university performance, and digital script licensing market. | SOM $12M — Web3-native playwrights and experimental theater companies using Base for transparent revenue splits. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proscenium" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized script-clearing protocol where every page turn, digital rehearsal, or performance reading triggers an instant $0.01 royalty distribution. Instead of lumpy, delayed annual checks, the x402 primitive enables 'metered licensing': theater troupes pay per act performed, with USDC streamed instantly to the playwright and estate via HTS transfer. Payment isn't just a fee; it is the verifiable permission to perform. Discipline: Theater & Live Performance (royalty management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Legacy royalty tracking is opaque and slow. x402 turns a script into a living asset where micro-usage (reading a scene) scales to macro-licensing (performing a show) through the same granular payment rail. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proscenium" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-performance-highlight-reels-22-x402 Title: Spotlight · x402 Theme: Theater & Live Performance (theater) · promotion Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view talent scouting engine. Producers and casting directors pay 0.01 USDC to unlock an actor's "Proof of Performance" highlight reel. Each payment triggers an on-chain receipt that serves as a high-intent lead for the actor, while the actor pays 0.01 USDC to update their reel, ensuring the talent pool remains fresh and active rather than stale metadata. Why Hedera: Traditional reels are lost in emails or static profiles. By making the reel a metered asset, we create a 'Proof of Interest' mechanism. For the actor, 0.01 USDC to upload prevents spam; for the scout, 0.01 USDC to view signals professional intent. The facilitator handles the HTS transfer signature, making the transaction frictionless during high-speed casting sessions. Market: TAM $2.8B — The global theatrical production and talent management industry. | SAM $450M — The digital talent acquisition and professional scouting market for global arts. | SOM $12M — Independent theatre casting and 'open call' digital submissions on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Spotlight" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view talent scouting engine. Producers and casting directors pay 0.01 USDC to unlock an actor's "Proof of Performance" highlight reel. Each payment triggers an on-chain receipt that serves as a high-intent lead for the actor, while the actor pays 0.01 USDC to update their reel, ensuring the talent pool remains fresh and active rather than stale metadata. Discipline: Theater & Live Performance (promotion). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional reels are lost in emails or static profiles. By making the reel a metered asset, we create a 'Proof of Interest' mechanism. For the actor, 0.01 USDC to upload prevents spam; for the scout, 0.01 USDC to view signals professional intent. The facilitator handles the HTS transfer signature, making the transaction frictionless during high-speed casting sessions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Spotlight" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-virtual-set-nfts-23-x402 Title: STAGESTREAM · x402 Theme: Theater & Live Performance (theater) · digital scenography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Project a high-fidelity digital environment onto your physical stage instantly. Performers use HTS transfer to authorize sub-cent streaming fees for dynamic geometry and lighting data. Instead of buying static assets, theater troupes pay-per-act to render premium scenography, allowing indie plays to access Broadway-tier VFX without upfront licensing costs. Revenue flows directly to the digital scenographer per frame rendered. Why Hedera: Traditional set design is a massive upfront CAPEX. x402 turns scenography into OPEX, where the stage hardware pulls data from an encrypted design layer strictly as needed, gated by per-use micropayments for every scene change. Market: TAM $12.4B — The evolving 'Phygital' entertainment market encompassing concerts, theme parks, and hybrid live events. | SAM $1.8B — Global theatrical production and live event technology market. | SOM $45M — Experimental digital theater, black-box VR performances, and indie Fringe festivals adopting spatial computing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STAGESTREAM" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Project a high-fidelity digital environment onto your physical stage instantly. Performers use HTS transfer to authorize sub-cent streaming fees for dynamic geometry and lighting data. Instead of buying static assets, theater troupes pay-per-act to render premium scenography, allowing indie plays to access Broadway-tier VFX without upfront licensing costs. Revenue flows directly to the digital scenographer per frame rendered. Discipline: Theater & Live Performance (digital scenography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional set design is a massive upfront CAPEX. x402 turns scenography into OPEX, where the stage hardware pulls data from an encrypted design layer strictly as needed, gated by per-use micropayments for every scene change. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STAGESTREAM" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA theater-script-ownership-registry-24-x402 Title: Ghostlight · x402 Theme: Theater & Live Performance (theater) · legal protection Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Protect your intellectual property in the rehearsal room. Playwrights upload script drafts to an immutable ledger; collaborators, directors, and literary agents pay 0.01 USDC to view, download, or time-stamp notes. Every revision is a new hash, every read is a metered payment, ensuring creators are compensated for the 'litigation-grade' proof of their work's evolution. Why Hedera: Transitioning from a static NFT 'trophy' to an x402 utility transforms ownership into active monetization. Instead of a one-time minting cost, the playwright earns every time their IP is accessed for professional review, turning legal protection into a recurring revenue stream. Market: TAM $2.4B — The global intellectual property protection and digital rights management market for performance arts and creative writing. | SAM $180M — The addressable market for playwrights, screenwriters, and dramaturgs engaging in digital script circulation and stage-rights management. | SOM $12M — The market for independent playwrights and fringe festival participants requiring affordable, instant copyright protection for draft iterations. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Ghostlight" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Protect your intellectual property in the rehearsal room. Playwrights upload script drafts to an immutable ledger; collaborators, directors, and literary agents pay 0.01 USDC to view, download, or time-stamp notes. Every revision is a new hash, every read is a metered payment, ensuring creators are compensated for the 'litigation-grade' proof of their work's evolution. Discipline: Theater & Live Performance (legal protection). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from a static NFT 'trophy' to an x402 utility transforms ownership into active monetization. Instead of a one-time minting cost, the playwright earns every time their IP is accessed for professional review, turning legal protection into a recurring revenue stream. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Ghostlight" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ============================================================================== THEME · Videography & Film videographers, editors, content creators ============================================================================== ------------------------------------------------------------------------------ IDEA video-framechain-auth-0-x402 Title: FrameGuard · x402 Theme: Videography & Film (video) · video copyright Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Per-frame attestation. A micropayment-metered verification engine for filmmakers. Protect your raw files by anchoring unique visual fingerprints to Base at the moment of capture. Producers pay per timestamp to generate a legally verifiable chain of custody, effectively ending deepfake disputes and metadata stripping. AI scraping agents must pay the x402 toll to verify rights before inclusion in training sets. Why Hedera: By moving from a 'subscription' model to a 'per-frame/per-timestamp' model, the cost scales with the volume of work. It turns the blockchain into a high-frequency notary for raw footage. Market: TAM $9.5B — The global digital rights management (DRM) and video authentication market, including AI training data validation. | SAM $420M — Professional videographers, legal tech firms, and news organizations requiring frame-level provenance. | SOM $12M — Independent documentary filmmakers and boutique agencies securing high-value intellectual property on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameGuard" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Per-frame attestation. A micropayment-metered verification engine for filmmakers. Protect your raw files by anchoring unique visual fingerprints to Base at the moment of capture. Producers pay per timestamp to generate a legally verifiable chain of custody, effectively ending deepfake disputes and metadata stripping. AI scraping agents must pay the x402 toll to verify rights before inclusion in training sets. Discipline: Videography & Film (video copyright). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a 'subscription' model to a 'per-frame/per-timestamp' model, the cost scales with the volume of work. It turns the blockchain into a high-frequency notary for raw footage. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameGuard" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-editversion-ledger-1-x402 Title: CUTSCENE · x402 Theme: Videography & Film (video) · edit history Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A provable version-control layer for post-production. $0.01 per version commit creates an immutable, timestamped 'state-save' on Hedera. Pay to pull the history of any clip, ensuring editors get paid per revision and directors have a verifiable audit trail of every frame change. No more 'Final_Final_v2.mp4' confusion. Why Hedera: By turning the 'Save' or 'Commit' action into a micro-transaction, we move from passive cloud logging to an active, paid ledger. This prevents 'edit-creep' by attaching a cost to every revision request and provides a crytographic proof-of-work for freelance editors. Market: TAM $2.8B — The global video editing software market shifting toward decentralized collaborative workflows. | SAM $420M — Professional freelance editors, colorists, and VFX artists using automated export-to-ledger plugins. | SOM $18M — Independent commercial production houses on Hedera requiring transparent billing for client revisions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CUTSCENE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A provable version-control layer for post-production. $0.01 per version commit creates an immutable, timestamped 'state-save' on Hedera. Pay to pull the history of any clip, ensuring editors get paid per revision and directors have a verifiable audit trail of every frame change. No more 'Final_Final_v2.mp4' confusion. Discipline: Videography & Film (edit history). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the 'Save' or 'Commit' action into a micro-transaction, we move from passive cloud logging to an active, paid ledger. This prevents 'edit-creep' by attaching a cost to every revision request and provides a crytographic proof-of-work for freelance editors. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CUTSCENE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-cliplicense-swap-2-x402 Title: RawCut · x402 Theme: Videography & Film (video) · licensed assets Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular video asset mart where creators pay-per-frame to license raw B-roll. No subscriptions, no bulky contracts—0.01 USDC triggers an instant HTS transfer transfer that fetches a high-res, watermarked-removed download link via the facilitator. Ideal for short-form editors who need a single 3-second transition without buying a $50/mo library access. Why Hedera: Legacy licensing is bloated. x402 turns video clips into 'pay-per-use' primitives, allowing editors to meter their production costs by the millisecond and ensuring cinematographers get paid instantly for every single asset pull. Market: TAM $26B — Global stock footage and digital media licensing market moving toward programmatic micro-distribution. | SAM $4.8B — The creator economy segment focusing on short-form social video (TikTok/Reels/Shorts) requiring frequent B-roll. | SOM $120M — Base-native editors and decentralized media houses using USDC-automated workflows for fast-turnaround content. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RawCut" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular video asset mart where creators pay-per-frame to license raw B-roll. No subscriptions, no bulky contracts—0.01 USDC triggers an instant HTS transfer transfer that fetches a high-res, watermarked-removed download link via the facilitator. Ideal for short-form editors who need a single 3-second transition without buying a $50/mo library access. Discipline: Videography & Film (licensed assets). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Legacy licensing is bloated. x402 turns video clips into 'pay-per-use' primitives, allowing editors to meter their production costs by the millisecond and ensuring cinematographers get paid instantly for every single asset pull. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RawCut" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-colorgrade-nft-3-x402 Title: CHROMA · x402 Theme: Videography & Film (video) · color grading Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A professional-grade LUT extraction and application engine where users pay 0.01 USDC to instantly apply premium cinema-grade color science to their footage or export a mobile-ready filter. Each grading operation is an onchain event, ensuring creators get paid per frame processed or per preset unlocked. Why Hedera: Shifts from bulky NFT speculation to a high-velocity utility model. By charging per 'Apply' or 'Export', it turns color grading into a metered service compatible with both human hobbyists and automated AI video workflows. Market: TAM $4.5B — Global video editing software and digital asset marketplaces. | SAM $800M — Mobile and desktop videographers using logic-based LUTs and presets. | SOM $25M — Base-native creators and automated video processing pipelines using USDC. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CHROMA" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A professional-grade LUT extraction and application engine where users pay 0.01 USDC to instantly apply premium cinema-grade color science to their footage or export a mobile-ready filter. Each grading operation is an onchain event, ensuring creators get paid per frame processed or per preset unlocked. Discipline: Videography & Film (color grading). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from bulky NFT speculation to a high-velocity utility model. By charging per 'Apply' or 'Export', it turns color grading into a metered service compatible with both human hobbyists and automated AI video workflows. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CHROMA" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-framerate-token-4-x402 Title: FrameCheck · x402 Theme: Videography & Film (video) · video metadata Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-frame technical verification. Metered access to raw cinematic metadata (EXIF, lens profiles, LUTs) for professional post-production workflows. Each fetch is a micro-settlement for the cinematographer. Why Hedera: Moving metadata from a 'passive' storage model to an 'active' query model. By charging 0.01 USDC per metadata pull, creators can monetize the technical specs of their stock footage or high-end dailies, turning technical data into a liquid asset. Market: TAM $3.1B — The global digital asset management and metadata services market. | SAM $420M — Professional colorists and VFX boutiques using Base for automated asset ingestion. | SOM $12M — Indie film festivals and boutique stock houses requiring instant metadata verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-frame technical verification. Metered access to raw cinematic metadata (EXIF, lens profiles, LUTs) for professional post-production workflows. Each fetch is a micro-settlement for the cinematographer. Discipline: Videography & Film (video metadata). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving metadata from a 'passive' storage model to an 'active' query model. By charging 0.01 USDC per metadata pull, creators can monetize the technical specs of their stock footage or high-end dailies, turning technical data into a liquid asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-scenesync-chain-5-x402 Title: Cutsign · x402 Theme: Videography & Film (video) · collaborative editing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Decentralized NLE state management where every 'save', 'render', or 'version-branch' is a 0.01 USDC event. Editors pay to push changes; producers pay to pull the latest sequence state. Eliminates cloud subscription bloat by metering the actual collaborative delta. Why Hedera: Collaborative video files are heavy, but the XML/Metadata sync is lightweight. By making every 'Sync' a micropayment, you monetize the coordination labor and metadata storage without high fixed monthly costs. Market: TAM $650B — The global media and entertainment software market shifting toward atomic, usage-based billing. | SAM $4.2B — Professional freelancers and remote post-production houses moving to pay-per-frame/pay-per-sync models. | SOM $18M — Early adopters on Hedera using frame-by-frame versioning for TikTok/Reel content houses. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Cutsign" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Decentralized NLE state management where every 'save', 'render', or 'version-branch' is a 0.01 USDC event. Editors pay to push changes; producers pay to pull the latest sequence state. Eliminates cloud subscription bloat by metering the actual collaborative delta. Discipline: Videography & Film (collaborative editing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Collaborative video files are heavy, but the XML/Metadata sync is lightweight. By making every 'Sync' a micropayment, you monetize the coordination labor and metadata storage without high fixed monthly costs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Cutsign" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-contenttrust-badge-6-x402 Title: DeepProof · x402 Theme: Videography & Film (video) · video authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Instantly verify the provenance and integrity of any video asset. ContentTrust enables filmmakers to sign metadata and users to verify authenticity for 0.01 USDC. Every verification call returns a Hedera transaction id, creating an immutable audit trail that prevents deepfake manipulation and preserves original intent. Pay per verification, protect per frame. Why Hedera: By moving from a static 'badge' to a pay-per-verification x402 model, the app transforms into a live security protocol. This creates a sustainable micro-economy for videographers to monetize their reputation and for viewers/newsrooms to filter truth from AI-generated noise. Market: TAM $4.2B — The global digital forensics and video authentication market, transitioning toward transparent onchain verification. | SAM $850M — The addressable market for decentralized identity and content provenance systems among independent creators and news outlets. | SOM $12M — Professional cinematographers, investigative journalists, and high-stakes content creators requiring immediate, cost-effective proof-of-authenticity. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DeepProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Instantly verify the provenance and integrity of any video asset. ContentTrust enables filmmakers to sign metadata and users to verify authenticity for 0.01 USDC. Every verification call returns a Hedera transaction id, creating an immutable audit trail that prevents deepfake manipulation and preserves original intent. Pay per verification, protect per frame. Discipline: Videography & Film (video authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a static 'badge' to a pay-per-verification x402 model, the app transforms into a live security protocol. This creates a sustainable micro-economy for videographers to monetize their reputation and for viewers/newsrooms to filter truth from AI-generated noise. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DeepProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-royaltysplit-dao-7-x402 Title: SpliceGate · x402 Theme: Videography & Film (video) · creator royalties Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency royalty distribution engine where viewership triggers instant micro-settlements. Instead of monthly tallies, every 'Play' or 'Download' event executes a $0.01 x402 stream that splits instantly across the collaborator graph. Payment isn't just a result; it's the heartbeat of the media player, ensuring no frame is rendered without a signed authorization and a confirmed Hedera transaction id. Why Hedera: By shifting from bulk distributions to per-view/per-interaction micropayments, creators eliminate the 'minimum payout' barrier. HTS transfer allows for frictionless sub-cent accounting that traditional DAOs cannot handle due to gas overhead. Market: TAM $250B — The global digital video content and creator economy market moving toward automated clearing houses. | SAM $1.2B — Emerging 'Pay-per-View' Web3 social media and decentralized CDN users. | SOM $85M — Independent film collectives and micro-influencers on Hedera utilizing HashPack for frictionless onboarding. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SpliceGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency royalty distribution engine where viewership triggers instant micro-settlements. Instead of monthly tallies, every 'Play' or 'Download' event executes a $0.01 x402 stream that splits instantly across the collaborator graph. Payment isn't just a result; it's the heartbeat of the media player, ensuring no frame is rendered without a signed authorization and a confirmed Hedera transaction id. Discipline: Videography & Film (creator royalties). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from bulk distributions to per-view/per-interaction micropayments, creators eliminate the 'minimum payout' barrier. HTS transfer allows for frictionless sub-cent accounting that traditional DAOs cannot handle due to gas overhead. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SpliceGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-scenetag-registry-8-x402 Title: SceneTag · x402 Theme: Videography & Film (video) · scene metadata Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micropayment layer for cinematic intelligence. Users pay a flat 0.01 USDC to instantly commit scene-level metadata—lighting setups, lens metadata, and continuity notes—to a global, searchable registry. Each tag is a signed assertion, turning raw footage into indexed, queryable assets for post-production houses and AI training sets. Payment ensures data integrity and rate-limits spam while building a royalty-ready ledger for scene contributors. Why Hedera: Current metadata workflows are fragmented across proprietary software. By making scene registration a 0.01 USDC primitive, we create a standardized 'proof-of-capture' that agents can query. The x402 model incentivizes accuracy: high-quality tags become assets that can be licensed, while the low cost makes high-volume tagging (frame-by-frame) viable for professional film sets. Market: TAM $2.8B — The global digital asset management (DAM) and video post-production market. | SAM $450M — The collaborative film production software market and metadata management niche. | SOM $12M — Independent film productions and decentralized AI video training collectors on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SceneTag" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micropayment layer for cinematic intelligence. Users pay a flat 0.01 USDC to instantly commit scene-level metadata—lighting setups, lens metadata, and continuity notes—to a global, searchable registry. Each tag is a signed assertion, turning raw footage into indexed, queryable assets for post-production houses and AI training sets. Payment ensures data integrity and rate-limits spam while building a royalty-ready ledger for scene contributors. Discipline: Videography & Film (scene metadata). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current metadata workflows are fragmented across proprietary software. By making scene registration a 0.01 USDC primitive, we create a standardized 'proof-of-capture' that agents can query. The x402 model incentivizes accuracy: high-quality tags become assets that can be licensed, while the low cost makes high-volume tagging (frame-by-frame) viable for professional film sets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SceneTag" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-scriptchain-ledger-9-x402 Title: DraftSeal · x402 Theme: Videography & Film (video) · screenplay tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A writer-to-producer proof protocol. Pay 0.10 USDC to generate a cryptographic 'Time-Lock Receipt' for your script draft. Producers pay 0.05 USDC to decrypt and read a watermarked version, with 80% flowing directly to the writer. No more 'stolen' ideas—every read is a signed, paid transaction on the Base ledger. Why Hedera: Reframes script protection from a passive database to a metered access layer. The x402 primitive handles both the 'Proof of Authorship' fee and the 'Pay-per-Read' gate, ensuring high-fidelity tracking of intellectual property distribution. Market: TAM $2.8B — Global entertainment IP management and legal discovery for script authorship disputes. | SAM $450M — Independent screenwriters, script consultants, and coverage services utilizing micro-fees for IP protection. | SOM $12M — Script-sharing for high-stakes competition submissions and early-stage writer-producer pitch rooms on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DraftSeal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A writer-to-producer proof protocol. Pay 0.10 USDC to generate a cryptographic 'Time-Lock Receipt' for your script draft. Producers pay 0.05 USDC to decrypt and read a watermarked version, with 80% flowing directly to the writer. No more 'stolen' ideas—every read is a signed, paid transaction on the Base ledger. Discipline: Videography & Film (screenplay tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Reframes script protection from a passive database to a metered access layer. The x402 primitive handles both the 'Proof of Authorship' fee and the 'Pay-per-Read' gate, ensuring high-fidelity tracking of intellectual property distribution. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DraftSeal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-clipstake-platform-10-x402 Title: FrameRate · x402 Theme: Videography & Film (video) · video staking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to unlock a fractional stake in a raw clip's downstream licensing revenue. Every view, edit, or repurpose by other creators triggers a micropayment distribution back to the early stakers via the x402 settlement layer. Skip the heavy DeFi UI; payment is the signal. Why Hedera: By replacing manual 'staking' with an instant 0.01 USDC event, we remove the friction of liquidity pools. The x402 transaction serves as both the funding mechanism and the cryptographically signed ledger entry for revenue participation. Market: TAM $24B — The global content creator economy and decentralized IP rights market. | SAM $850M — High-velocity short-form content platforms and stock footage marketplaces. | SOM $12M — Indie filmmakers and 'raw-cut' creators on Hedera seeking alternative monetization. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameRate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to unlock a fractional stake in a raw clip's downstream licensing revenue. Every view, edit, or repurpose by other creators triggers a micropayment distribution back to the early stakers via the x402 settlement layer. Skip the heavy DeFi UI; payment is the signal. Discipline: Videography & Film (video staking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing manual 'staking' with an instant 0.01 USDC event, we remove the friction of liquidity pools. The x402 transaction serves as both the funding mechanism and the cryptographically signed ledger entry for revenue participation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameRate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-autosubtitle-mint-11-x402 Title: ScriptCipher · x402 Theme: Videography & Film (video) · subtitle generation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Film-grade subtitle generation where every timestamp is cryptographically anchored. Pay 0.01 USDC per minute of processed dialogue to generate, sync, and sign .srt files via Base. Use it to gate premium content or provide tamper-proof transcripts for legal and archival videography. No subscriptions, just compute-on-demand. Why Hedera: By moving from a 'mint' model to a metered 'usage' model, we capture the high-frequency nature of post-production. Each call triggers an HTS transfer transfer, turning the subtitle file into a paid asset that is verified upon delivery. Market: TAM $8.4B — The total economy of AI-driven content accessibility and global distribution. | SAM $1.2B — The global video localization and transcription market. | SOM $15M — Indie creators and legal videographers requiring verified Base-settled transcripts. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScriptCipher" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Film-grade subtitle generation where every timestamp is cryptographically anchored. Pay 0.01 USDC per minute of processed dialogue to generate, sync, and sign .srt files via Base. Use it to gate premium content or provide tamper-proof transcripts for legal and archival videography. No subscriptions, just compute-on-demand. Discipline: Videography & Film (subtitle generation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a 'mint' model to a metered 'usage' model, we capture the high-frequency nature of post-production. Each call triggers an HTS transfer transfer, turning the subtitle file into a paid asset that is verified upon delivery. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScriptCipher" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-videocollab-dao-12-x402 Title: FinalCut · x402 Theme: Videography & Film (video) · collaborative project management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 per decision vote. Shift video production from bureaucratic DAOs to a real-time, pay-to-play creative engine. Writers, editors, and colorists stake small USDC micropayments to commit edit logs, approve daily rushes, or trigger a final render. No governance bloating—just a metered stream of creative consensus where every frame adjustment is a settled transaction on-chain. Why Hedera: Existing film DAOs fail due to voting apathy and gas costs. By using x402, every creative 'opinion' or 'submission' becomes a micro-transaction. This creates a high-velocity feedback loop where the 'skin in the game' for an edit decision is exactly 0.01 USDC, instantly liquid and transparent. Market: TAM $14.5B — Global video production and post-production management software market. | SAM $850M — The independent film and boutique production agency market adopting web3-native collaboration tools. | SOM $12M — Decentralized film collectives and freelance video editor pods using Base for low-cost project settlement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FinalCut" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 per decision vote. Shift video production from bureaucratic DAOs to a real-time, pay-to-play creative engine. Writers, editors, and colorists stake small USDC micropayments to commit edit logs, approve daily rushes, or trigger a final render. No governance bloating—just a metered stream of creative consensus where every frame adjustment is a settled transaction on-chain. Discipline: Videography & Film (collaborative project management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Existing film DAOs fail due to voting apathy and gas costs. By using x402, every creative 'opinion' or 'submission' becomes a micro-transaction. This creates a high-velocity feedback loop where the 'skin in the game' for an edit decision is exactly 0.01 USDC, instantly liquid and transparent. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FinalCut" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-nftscene-frames-13-x402 Title: FREEZEFRAME · x402 Theme: Videography & Film (video) · unique video moments Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-frame high-fidelity capture. A dynamic bridge between long-form video and high-quality photography. Users browse video timelines and pay a 0.01 USDC micro-settlement to extract, up-res, and claim the master-quality raw frame. By replacing the 'NFT minting' friction with an instant 'pay-to-capture' primitive, creators earn high-velocity revenue for every frame saved by fans, while movie buffs curate 'The Cut' of their favorite scenes one cent at a time. Why Hedera: Transitioning from 'NFT ownership' to 'micropayment extraction' lowers the barrier to entry. Instead of one person owning a frame, thousands of people pay 0.01 USDC to 'save' it, creating a high-frequency revenue stream for rights holders settled instantly via the embedded wallet-signed HTS transfer. Market: TAM $3.8B — Global digital collectibles and video-on-demand secondary markets, shifting toward granular, micro-transactional asset access. | SAM $280M — The segment of the creator economy specifically engaged in high-end video production and fan curation platforms. | SOM $14M — Initial capture revenue from independent film enthusiasts and social media curators using Base for instant asset claiming. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FREEZEFRAME" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-frame high-fidelity capture. A dynamic bridge between long-form video and high-quality photography. Users browse video timelines and pay a 0.01 USDC micro-settlement to extract, up-res, and claim the master-quality raw frame. By replacing the 'NFT minting' friction with an instant 'pay-to-capture' primitive, creators earn high-velocity revenue for every frame saved by fans, while movie buffs curate 'The Cut' of their favorite scenes one cent at a time. Discipline: Videography & Film (unique video moments). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from 'NFT ownership' to 'micropayment extraction' lowers the barrier to entry. Instead of one person owning a frame, thousands of people pay 0.01 USDC to 'save' it, creating a high-frequency revenue stream for rights holders settled instantly via the embedded wallet-signed HTS transfer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FREEZEFRAME" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-chainfeedback-loop-14-x402 Title: FinalCut Pay · x402 Theme: Videography & Film (video) · editorial feedback Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A time-stamped, frame-accurate review layer where every critique costs. Creators pay 0.01 USDC to request a frame-specific review (HTS transfer), and editors earn for each actionable annotation submitted. By metering the feedback loop, production houses eliminate 'feedback fatigue' and ensure every revision cycle is backed by a Base settlement, turning vague emails into a high-signal, paid data stream for the final cut. Why Hedera: Shift the burden of 'infinite revisions' to 'valued micro-contributions.' By putting a 0.01 USDC price tag on every specific timestamped comment, the app filters out noise and incentivizes high-quality, professional editorial eyes. No payment, no timestamped note. Market: TAM $4.2B — The global video editing software and cloud collaboration market transitioning to micro-transactional service models. | SAM $850M — The addressable market for decentralized post-production workflows and remote editorial collaboration tools. | SOM $12M — Specialized freelance film editors and boutique production houses utilizing micropayment-gated review cycles. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FinalCut Pay" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A time-stamped, frame-accurate review layer where every critique costs. Creators pay 0.01 USDC to request a frame-specific review (HTS transfer), and editors earn for each actionable annotation submitted. By metering the feedback loop, production houses eliminate 'feedback fatigue' and ensure every revision cycle is backed by a Base settlement, turning vague emails into a high-signal, paid data stream for the final cut. Discipline: Videography & Film (editorial feedback). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shift the burden of 'infinite revisions' to 'valued micro-contributions.' By putting a 0.01 USDC price tag on every specific timestamped comment, the app filters out noise and incentivizes high-quality, professional editorial eyes. No payment, no timestamped note. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FinalCut Pay" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-sceneunlock-token-15-x402 Title: Cutt · x402 Theme: Videography & Film (video) · content access control Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC per frame or sequence to instantly decrypt exclusive B-roll, director's cuts, or raw dailies. No subscriptions; just a signed HTS transfer message for every 'Play' click, settling instantly on Hedera. Why Hedera: Traditional paywalls create friction for casual viewers. By making the payment primitive (0.01 USDC per unlock), creators can monetize at the scene level, and AI video-indexing agents can pay to 'ingest' content frame-by-frame. Market: TAM $45B — The global SVOD and digital video licensing market transitioning to granular, pay-per-view micro-settlement. | SAM $850M — Independent film distribution and premium social video creators adopting micropayment models. | SOM $12M — Early-stage Base creators and film collectives using HashPack-integrated x402 gates for exclusive scene drops. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Cutt" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC per frame or sequence to instantly decrypt exclusive B-roll, director's cuts, or raw dailies. No subscriptions; just a signed HTS transfer message for every 'Play' click, settling instantly on Hedera. Discipline: Videography & Film (content access control). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional paywalls create friction for casual viewers. By making the payment primitive (0.01 USDC per unlock), creators can monetize at the scene level, and AI video-indexing agents can pay to 'ingest' content frame-by-frame. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Cutt" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-colorswap-market-16-x402 Title: Chromata · x402 Theme: Videography & Film (video) · color palette trading Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-lookup library for cinematic LUTs and RAW color profiles. Videographers pay $0.01 USDC to instantly unlock the hex-data or .cube values for a frame. Every 'Apply Palette' action in the editor triggers a micro-settlement to the colorist via a the embedded wallet-signed request. No subscriptions—just pay for the specific look of the shot you're grading. Why Hedera: Traditional asset marketplaces suffer from 'all-or-nothing' licensing. By atomizing color data into x402 calls, high-end colorists can monetize every single 'preview' or 'apply' action, turning a color gallery into a high-velocity automated revenue stream. Market: TAM $4.2B — Global digital video production and post-production market. | SAM $850M — Revenue from specialized video plugins, LUT packs, and post-production software. | SOM $18M — High-frequency color grading API calls for indie filmmakers and mobile video apps. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chromata" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-lookup library for cinematic LUTs and RAW color profiles. Videographers pay $0.01 USDC to instantly unlock the hex-data or .cube values for a frame. Every 'Apply Palette' action in the editor triggers a micro-settlement to the colorist via a the embedded wallet-signed request. No subscriptions—just pay for the specific look of the shot you're grading. Discipline: Videography & Film (color palette trading). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional asset marketplaces suffer from 'all-or-nothing' licensing. By atomizing color data into x402 calls, high-end colorists can monetize every single 'preview' or 'apply' action, turning a color gallery into a high-velocity automated revenue stream. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chromata" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-clipproof-archive-17-x402 Title: TrueFrame · x402 Theme: Videography & Film (video) · immutable clip storage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hard-locked vault for cinema-grade evidence and master clips. Users pay 0.01 USDC to seal a frame or clip hash to Base, generating an immutable proof of capture. Retrieval and verification are metered, turning 'indisputable provenance' into a micro-transactional service for journalists, litigants, and creators. Payment triggers the instant onchain notarization. Why Hedera: By shifting from a subscription archive to a pay-per-seal model, the cost of provenance scales linearly with the volume of assets. Using x402 allows for frictionless, high-frequency timestamping during a live shoot or upload flow, ensuring every file has a cryptographically verifiable birth date. Market: TAM $9.5B — Global digital asset management and blockchain-based digital identity for media assets. | SAM $480M — Independent filmmakers, legal videographers, and citizen journalists requiring tamper-proof metadata. | SOM $1.2M — On-set production assistants and mobile journalists using Base for real-time asset hashing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueFrame" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hard-locked vault for cinema-grade evidence and master clips. Users pay 0.01 USDC to seal a frame or clip hash to Base, generating an immutable proof of capture. Retrieval and verification are metered, turning 'indisputable provenance' into a micro-transactional service for journalists, litigants, and creators. Payment triggers the instant onchain notarization. Discipline: Videography & Film (immutable clip storage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a subscription archive to a pay-per-seal model, the cost of provenance scales linearly with the volume of assets. Using x402 allows for frictionless, high-frequency timestamping during a live shoot or upload flow, ensuring every file has a cryptographically verifiable birth date. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueFrame" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-livecut-chain-18-x402 Title: StreamTape · x402 Theme: Videography & Film (video) · live edit tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Per Metadata Stamp. A forensic logging layer for live broadcast. Every camera switch, color adjustment, and transition is signed by the switcher and anchored to Base in real-time. Producers pay-per-cut to generate an immutable, audit-ready EDL (Edit Decision List) that compensates crew based on live performance data. Why Hedera: Moving from 'secure records' to a 'utility meter' model. By charging per transition, the protocol creates a verifiable proof-of-work for live editors, allowing for instant programmatic payouts to technical directors based on the complexity and volume of the live edit. Market: TAM $6.8B — The global live media production and broadcast software market migrating to transparent, automated ledger systems. | SAM $420M — Live event production houses and decentralized streaming platforms (Livepeer, Kick) adopting real-time metadata standards. | SOM $12M — Independent live-streamers and esports broadcasters requiring verifiable proof of edit for sponsor compliance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StreamTape" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Per Metadata Stamp. A forensic logging layer for live broadcast. Every camera switch, color adjustment, and transition is signed by the switcher and anchored to Base in real-time. Producers pay-per-cut to generate an immutable, audit-ready EDL (Edit Decision List) that compensates crew based on live performance data. Discipline: Videography & Film (live edit tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'secure records' to a 'utility meter' model. By charging per transition, the protocol creates a verifiable proof-of-work for live editors, allowing for instant programmatic payouts to technical directors based on the complexity and volume of the live edit. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StreamTape" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-captiontag-nft-19-x402 Title: LENSFLOW · x402 Theme: Videography & Film (video) · caption ownership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Access-controlled subtitle layers. Embed metadata-rich caption files (SRT/VTT) into video players that only render upon a signed x402 micro-transaction. Pay the translator or writer per view, not per project. Every time a viewer toggles your CC track, you settle a sub-penny royalty instantly to your Magic Link email sign-in. Validates ownership through consumption rather than static minting. Why Hedera: Static NFTs for text are illiquid. x402 turns captions into a metered utility. By moving the payment to the 'toggle' event, we create a recurring revenue stream for scriptwriters and localization specialists, turning every frame of a video into a potential micro-toll for creative labor. Market: TAM $28B — The total addressable 'Creator Economy' spend on post-production and distribution tools. | SAM $4.1B — The global language services and subtitling market, specifically digital-first creators. | SOM $85M — Independent YouTubers and documentary filmmakers using Base to bypass agency middle-men for localization. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LENSFLOW" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Access-controlled subtitle layers. Embed metadata-rich caption files (SRT/VTT) into video players that only render upon a signed x402 micro-transaction. Pay the translator or writer per view, not per project. Every time a viewer toggles your CC track, you settle a sub-penny royalty instantly to your Magic Link email sign-in. Validates ownership through consumption rather than static minting. Discipline: Videography & Film (caption ownership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Static NFTs for text are illiquid. x402 turns captions into a metered utility. By moving the payment to the 'toggle' event, we create a recurring revenue stream for scriptwriters and localization specialists, turning every frame of a video into a potential micro-toll for creative labor. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LENSFLOW" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-framerate-oracle-20-x402 Title: SyncGuard · x402 Theme: Videography & Film (video) · metadata validation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay per validation to cryptographically certify video frame-rates and clock-sync metadata. Each 0.01 USDC call generates a signed attestation, preventing playback drift and 'fake-high-res' upscaling in decentralized streaming pipelines. Why Hedera: In a world of synthetic media and fragmented CDNs, frame-rate integrity is the bedrock of visual truth. By making metadata validation a paid x402 utility, we turn technical hygiene into a verifiable asset class. Every frame-rate check is a micropayment that protects the value of high-end cinematography. Market: TAM $5.2B — The global digital asset management and metadata integrity market for media. | SAM $450M — Post-production houses and Web3 VOD platforms requiring automated QC. | SOM $18M — Independent cinematographers and DAOs archiving high-fidelity film assets on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SyncGuard" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay per validation to cryptographically certify video frame-rates and clock-sync metadata. Each 0.01 USDC call generates a signed attestation, preventing playback drift and 'fake-high-res' upscaling in decentralized streaming pipelines. Discipline: Videography & Film (metadata validation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: In a world of synthetic media and fragmented CDNs, frame-rate integrity is the bedrock of visual truth. By making metadata validation a paid x402 utility, we turn technical hygiene into a verifiable asset class. Every frame-rate check is a micropayment that protects the value of high-end cinematography. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SyncGuard" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-reelrights-dao-21-x402 Title: FrameGuard · x402 Theme: Videography & Film (video) · copyright governance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-velocity copyright clearinghouse and dispute resolution engine for short-form video. Instead of slow legal processes, creators and platforms pay 0.01 USDC to instantly query a video fingerprint's status, flag a violation, or cast a vote in an active governance dispute. Platforms meter copyright compliance at the frame/edit level, and jurors are micro-tipped for every resolution they validate. Rights management becomes a live, metered utility rather than a static document. Why Hedera: By converting copyright governance into a pay-per-use primitive, we eliminate the friction of legal retainers. Each action—from checking a license to submitting a dispute—is a micro-transaction that funds a decentralized pool of human and AI jurors. This creates a sustainable, real-time 'Court of Content' where enforcement is as fast as the feed. Market: TAM $30B — The global creative economy and content moderation industry, encompassing all video platforms, social media, and ad-tech compliance. | SAM $4.2B — The total market for global digital rights management (DRM) and specialized video licensing tools. | SOM $120M — Short-form video platforms and individual creators on Hedera/Hedera testnet seeking instant, cross-platform copyright mediation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameGuard" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-velocity copyright clearinghouse and dispute resolution engine for short-form video. Instead of slow legal processes, creators and platforms pay 0.01 USDC to instantly query a video fingerprint's status, flag a violation, or cast a vote in an active governance dispute. Platforms meter copyright compliance at the frame/edit level, and jurors are micro-tipped for every resolution they validate. Rights management becomes a live, metered utility rather than a static document. Discipline: Videography & Film (copyright governance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By converting copyright governance into a pay-per-use primitive, we eliminate the friction of legal retainers. Each action—from checking a license to submitting a dispute—is a micro-transaction that funds a decentralized pool of human and AI jurors. This creates a sustainable, real-time 'Court of Content' where enforcement is as fast as the feed. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameGuard" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-cliptip-payments-22-x402 Title: RAWFEED · x402 Theme: Videography & Film (video) · micro-tipping Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless frame-based protocol for frame-by-frame appreciation. Instead of a 'Like', viewers pay 0.01 USDC to unlock the high-definition 'Director's Cut' metadata or specific technical LUTs used in a scene. It transforms passive consumption into a metered stream of support, where each second of professional-grade footage is an x402-gated asset, allowing creators to monetize the 'how' as much as the 'what'. Why Hedera: Current tipping is a psychological friction point. By integrating x402 via HTS transfer, 'tipping' becomes a functional payment for metadata (camera settings, set notes, or raw clips). This shifts the behavior from charity to a micro-transactional value exchange that is gasless for the user and instant for the creator. Market: TAM $105B — The global digital video content market moving toward granular, per-view or per-asset monetization models. | SAM $450M — The growing economy of 'Edutainment' and short-form video creators on platforms like Farcaster, TikTok, and Reels who seek direct-to-fan monetization. | SOM $12M — Initial capture of the onchain creative community on Hedera using Frames and social-fi layers to gate premium technical assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RAWFEED" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless frame-based protocol for frame-by-frame appreciation. Instead of a 'Like', viewers pay 0.01 USDC to unlock the high-definition 'Director's Cut' metadata or specific technical LUTs used in a scene. It transforms passive consumption into a metered stream of support, where each second of professional-grade footage is an x402-gated asset, allowing creators to monetize the 'how' as much as the 'what'. Discipline: Videography & Film (micro-tipping). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current tipping is a psychological friction point. By integrating x402 via HTS transfer, 'tipping' becomes a functional payment for metadata (camera settings, set notes, or raw clips). This shifts the behavior from charity to a micro-transactional value exchange that is gasless for the user and instant for the creator. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RAWFEED" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-motiontrack-chain-23-x402 Title: KINETIC · x402 Theme: Videography & Film (video) · motion metadata Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity motion metadata relay for VFX houses and remote editors. Pay 0.01 USDC per frame to stream verified XYZ coordinates and lens telemetry directly into your compositor. Eliminate manual tracking labor and 'black box' data handover with a cryptographically signed audit trail of every camera move. Why Hedera: By turning motion metadata into a metered stream, the camera operator is paid in real-time for the quality of their data, and post-production teams only pay for the specific frames they process. It replaces bulky file transfers with a pay-per-use data API. Market: TAM $5.2B — The total addressable cinematography and digital imaging metadata market. | SAM $850M — The global post-production and VFX outsourcing market. | SOM $25M — Indie VFX boutiques and remote freelance compositors requiring verified tracking data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity motion metadata relay for VFX houses and remote editors. Pay 0.01 USDC per frame to stream verified XYZ coordinates and lens telemetry directly into your compositor. Eliminate manual tracking labor and 'black box' data handover with a cryptographically signed audit trail of every camera move. Discipline: Videography & Film (motion metadata). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning motion metadata into a metered stream, the camera operator is paid in real-time for the quality of their data, and post-production teams only pay for the specific frames they process. It replaces bulky file transfers with a pay-per-use data API. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-scenemint-hub-24-x402 Title: SceneCut · x402 Theme: Videography & Film (video) · scene NFT marketplace Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cinematic clip-layer allowing editors to license specific scenes for $0.01 per second or per download. x402 enables granular, frame-perfect micro-royalties where film creators get paid instantly as their clips are ported into new timelines, bypassing bulk licensing friction. Why Hedera: Shifts from lumpy NFT trading to high-frequency usage utility. Metadata tags (HTS transfer) trigger payments as editors 'pull' assets into local NLEs. Market: TAM $18B - Worldwide digital rights management and video creative economies. | SAM $4.2B - Global stock footage and cinematic asset licensing market. | SOM $50M - On-chain video editors, AI video generators, and web3 native content houses. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SceneCut" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cinematic clip-layer allowing editors to license specific scenes for $0.01 per second or per download. x402 enables granular, frame-perfect micro-royalties where film creators get paid instantly as their clips are ported into new timelines, bypassing bulk licensing friction. Discipline: Videography & Film (scene NFT marketplace). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from lumpy NFT trading to high-frequency usage utility. Metadata tags (HTS transfer) trigger payments as editors 'pull' assets into local NLEs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SceneCut" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-framesync-ledger-0-x402 Title: FrameSync · x402 Theme: Videography & Film (video) · version control Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-commit protocol for collaborative film editing. Instead of expensive cloud subscriptions, FrameSync charges $0.01 to notarize a new edit hash to Base. Pro-tier editors 'unlock' the master XML/Timeline file from colleagues for a micropayment, ensuring the latest version is never free-ridden and creators get paid per-view during the review cycle. Why Hedera: Shifts version control from a storage cost to a transactional event. By metering the 'pull' and 'push' of edit metadata, it prevents version bloat and creates a clear financial audit trail for post-production houses. Market: TAM $5.4B — Global professional video editing software and cloud collaboration market. | SAM $850M — The shared storage and collaborative software market for independent video production. | SOM $12M — Freelance editors and boutique VFX houses using decentralized tools to bypass Adobe/Blackmagic cloud lock-in. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameSync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-commit protocol for collaborative film editing. Instead of expensive cloud subscriptions, FrameSync charges $0.01 to notarize a new edit hash to Base. Pro-tier editors 'unlock' the master XML/Timeline file from colleagues for a micropayment, ensuring the latest version is never free-ridden and creators get paid per-view during the review cycle. Discipline: Videography & Film (version control). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts version control from a storage cost to a transactional event. By metering the 'pull' and 'push' of edit metadata, it prevents version bloat and creates a clear financial audit trail for post-production houses. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameSync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-clipstamp-vault-1-x402 Title: ClipStamp · x402 Theme: Videography & Film (video) · content authentication Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn your camera roll into an immutable legal record. A $0.01 micro-transaction triggers an automated HTS transfer signed metadata hash anchored to Hedera testnet. Don't just claim authorship; prove the exact millisecond of creation to prevent AI scraping and deepfake plagiarism. Pay-per-frame authentication for creators who value their provenance. Why Hedera: By shifting from a subscription model to a pay-per-timestamp (x402) model, the app captures the high-frequency needs of short-form creators. The friction of payment is eliminated by the the embedded wallet-signed auth, turning 'Protect' into a seamless, one-tap button that generates a verifiable transaction hash for every clip. Market: TAM $850M — The global digital rights management (DRM) and content authentication market, increasingly driven by AI-generated content verification. | SAM $42M — Professional creators and independent videographers seeking legal-grade chain-of-custody for high-value raw footage. | SOM $2.1M — Early adopters in the crypto-media space and citizen journalists requiring tamper-proof video proof. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ClipStamp" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn your camera roll into an immutable legal record. A $0.01 micro-transaction triggers an automated HTS transfer signed metadata hash anchored to Hedera testnet. Don't just claim authorship; prove the exact millisecond of creation to prevent AI scraping and deepfake plagiarism. Pay-per-frame authentication for creators who value their provenance. Discipline: Videography & Film (content authentication). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a subscription model to a pay-per-timestamp (x402) model, the app captures the high-frequency needs of short-form creators. The friction of payment is eliminated by the the embedded wallet-signed auth, turning 'Protect' into a seamless, one-tap button that generates a verifiable transaction hash for every clip. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ClipStamp" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-storyboard-chain-2-x402 Title: SHOOTLIST · x402 Theme: Videography & Film (video) · preproduction planning Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Storyboard cells are locked behind $0.01 micro-transactions for collaborative review. A 'Director’s Cut' mode meters every AI-generated frame revision. Producers sign HTS transfer permits to unlock shot-lists, ensuring crew access is paid and IP access is cryptographically settled on-chain per view. Why Hedera: Traditional pre-production software uses monthly seats, which is inefficient for gig-based film crews. x402 allows for 'pay-per-shot' planning where every storyboard iteration or reference lookup is a discrete transaction, mapping production costs directly to planning volume. Market: TAM $16.5B — The global film and video production software market shifting toward modular, pay-as-you-go cloud architecture. | SAM $1.4B — Independent filmmakers, ad agencies, and pre-vis artists using digital sketching tools. | SOM $12M — AI-assisted storyboarding workflows where agents require micropayments to generate and serve frame sequences. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SHOOTLIST" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Storyboard cells are locked behind $0.01 micro-transactions for collaborative review. A 'Director’s Cut' mode meters every AI-generated frame revision. Producers sign HTS transfer permits to unlock shot-lists, ensuring crew access is paid and IP access is cryptographically settled on-chain per view. Discipline: Videography & Film (preproduction planning). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional pre-production software uses monthly seats, which is inefficient for gig-based film crews. x402 allows for 'pay-per-shot' planning where every storyboard iteration or reference lookup is a discrete transaction, mapping production costs directly to planning volume. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SHOOTLIST" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-colorgrade-provenance-3-x402 Title: LUTstream · x402 Theme: Videography & Film (video) · color grading Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Color grading LUTs and node-trees are treated as discrete digital assets. Pay 0.01 USDC to instantly pull a professional .cube or grading manifest from IPFS. Enable 'Metered Collaboration' where secondary editors pay per adjust-and-save, triggering a micro-royalty back to the original colorist on every render. Why Hedera: By moving grading profiles from static files to p2p-paid streams, we solve the 'last-mile' royalty problem for DITs and colorists. Payment is the key that decrypts the specific mapping data for the NLE plugin. Market: TAM $3.2B — Global post-production software and metadata market, encompassing digital asset management for film and streaming. | SAM $480M — Independent cinematographers and post-production houses transitioning to remote-cloud workflows and tokenized asset management. | SOM $8.5M — Niche pro-consumer marketplace for high-end cinematic LUTs and PowerGrades managed via automated micro-licenses. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LUTstream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Color grading LUTs and node-trees are treated as discrete digital assets. Pay 0.01 USDC to instantly pull a professional .cube or grading manifest from IPFS. Enable 'Metered Collaboration' where secondary editors pay per adjust-and-save, triggering a micro-royalty back to the original colorist on every render. Discipline: Videography & Film (color grading). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving grading profiles from static files to p2p-paid streams, we solve the 'last-mile' royalty problem for DITs and colorists. Payment is the key that decrypts the specific mapping data for the NLE plugin. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LUTstream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-motionmask-archive-4-x402 Title: GLYPH · x402 Theme: Videography & Film (video) · masking and effects Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A programmable repository for high-fidelity rotoscoping and motion masks. Users pay 0.01 USDC to pull a specific mask data-stream (Roto-JSON or alpha assets) directly into their editor via the x402 plugin. Eliminates subscription bloat for one-off VFX needs; creators earn instantly when their precision masks are called. Why Hedera: By turning motion masks into granular, pay-per-use assets, we shift from 'asset hosting' to 'mask-as-a-service.' This enables AI video agents and human editors to programmatically fetch complex masking data for single shots without buying enterprise seats or entire packs. Market: TAM $18B — Global digital video post-production and VFX market. | SAM $1.2B — Professional editors and motion designers utilizing mid-market VFX tools and stock assets. | SOM $45M — Niche VFX artists and AI video generation agents requiring high-precision rotoscopy data on-demand. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GLYPH" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A programmable repository for high-fidelity rotoscoping and motion masks. Users pay 0.01 USDC to pull a specific mask data-stream (Roto-JSON or alpha assets) directly into their editor via the x402 plugin. Eliminates subscription bloat for one-off VFX needs; creators earn instantly when their precision masks are called. Discipline: Videography & Film (masking and effects). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning motion masks into granular, pay-per-use assets, we shift from 'asset hosting' to 'mask-as-a-service.' This enables AI video agents and human editors to programmatically fetch complex masking data for single shots without buying enterprise seats or entire packs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GLYPH" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-soundsync-chain-5-x402 Title: PerfectSlate · x402 Theme: Videography & Film (video) · audio synchronization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to instantly generate and push high-fidelity timecode alignment metadata to IPFS. Editors pay per sync-operation to lock frames, ensuring global coordination without heavy file transfers. Each alignment signature is a verifiable proof-of-sync for chain-of-title documentation. Why Hedera: In professional post-production, manual sync is a bottleneck. By turning synchronization into a metered utility, production houses can automate the 'clapper' process for AI agents and freelance editors, paying only for the exact amount of footage processed. Market: TAM $2.4B — The global cloud-based video editing and collaborative post-production market. | SAM $180M — Independent film productions and decentralized post-production houses using Base for coordination. | SOM $12M — High-velocity short-form content agencies requiring instant, verified audio-video alignment. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PerfectSlate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to instantly generate and push high-fidelity timecode alignment metadata to IPFS. Editors pay per sync-operation to lock frames, ensuring global coordination without heavy file transfers. Each alignment signature is a verifiable proof-of-sync for chain-of-title documentation. Discipline: Videography & Film (audio synchronization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: In professional post-production, manual sync is a bottleneck. By turning synchronization into a metered utility, production houses can automate the 'clapper' process for AI agents and freelance editors, paying only for the exact amount of footage processed. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PerfectSlate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-cutlist-ledger-6-x402 Title: FinalCut · x402 Theme: Videography & Film (video) · editing workflows Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-commit protocol for professional film editors. Every time you snapshot a timeline revision or 'lock' a cut, pay 0.01 USDC to secure the metadata on-chain. Producers and collaborators pay per-view to unlock the latest high-fidelity project manifest, ensuring the edit history is an immutable, paid audit trail rather than a messy folder of .xml files. Why Hedera: Traditional post-production suffers from 'versioning hell.' By making each version a micro-transaction, you create a financial incentive for clean organization while using the Hedera transaction id as the definitive proof-of-work for billable hours. Market: TAM $2.8B — Global digital video editing and asset management ecosystem. | SAM $450M — The shared storage and post-production collaboration software market. | SOM $12M — Freelance editors and boutique post-houses on Hedera adopting verifiable workflow tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FinalCut" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-commit protocol for professional film editors. Every time you snapshot a timeline revision or 'lock' a cut, pay 0.01 USDC to secure the metadata on-chain. Producers and collaborators pay per-view to unlock the latest high-fidelity project manifest, ensuring the edit history is an immutable, paid audit trail rather than a messy folder of .xml files. Discipline: Videography & Film (editing workflows). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional post-production suffers from 'versioning hell.' By making each version a micro-transaction, you create a financial incentive for clean organization while using the Hedera transaction id as the definitive proof-of-work for billable hours. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FinalCut" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-vfx-asset-chain-7-x402 Title: RenderNode · x402 Theme: Videography & Film (video) · visual effects Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless VFX library where every asset—from 3D meshes to complex lighting rigs—is metered. Users pay a 0.01 USDC micro-transaction to pull the IPFS hash and decryption key for a specific asset via their Magic Link email sign-in. Perfect for high-speed collaborative post-production where creators are paid instantly as their presets or models are imported into a project's pipeline. Why Hedera: Traditional asset stores gatebehind $50+ subscriptions or high individual prices. VFX pipeline integration requires 'sampling' or 'testing' assets. x402 allows a friction-free 'pay-per-pull' model, turning a metadata index into a live, revenue-generating distribution stream for technical directors. Market: TAM $9.2B — The global 3D animation and rendering market. | SAM $480M — The visual effects software market and asset store economy. | SOM $12M — Independent VFX artists and boutique studios using Base for collaborative, atomic asset sharing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RenderNode" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless VFX library where every asset—from 3D meshes to complex lighting rigs—is metered. Users pay a 0.01 USDC micro-transaction to pull the IPFS hash and decryption key for a specific asset via their Magic Link email sign-in. Perfect for high-speed collaborative post-production where creators are paid instantly as their presets or models are imported into a project's pipeline. Discipline: Videography & Film (visual effects). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional asset stores gatebehind $50+ subscriptions or high individual prices. VFX pipeline integration requires 'sampling' or 'testing' assets. x402 allows a friction-free 'pay-per-pull' model, turning a metadata index into a live, revenue-generating distribution stream for technical directors. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RenderNode" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-captioncast-ipfs-8-x402 Title: SubGate · x402 Theme: Videography & Film (video) · subtitling and captions Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-performance subtitle delivery layer where filmmakers pay 0.01 USDC per CID resolution to fetch globally-redundant, version-synced captions. Every time a video player requests a subtitle file, the creator is settled instantly via the facilitator. Stop bundle-bloating video files; stream localized text data on-demand through an immutable, paid gateway. Why Hedera: Shifts captioning from a 'fixed' asset to a metered utility. Using x402 allows for granular billing where a 10-episode series only incurs costs for the specific languages a viewer actually toggles, creating a more efficient micro-economy for post-production houses. Market: TAM $4.2B — Global video accessibility and localization services market. | SAM $180M — The addressable market for decentralized media hosting and decentralized CDN infrastructure nodes. | SOM $12M — Series-A through Indie filmmakers and Web3 streaming platforms requiring per-view caption settlements. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SubGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-performance subtitle delivery layer where filmmakers pay 0.01 USDC per CID resolution to fetch globally-redundant, version-synced captions. Every time a video player requests a subtitle file, the creator is settled instantly via the facilitator. Stop bundle-bloating video files; stream localized text data on-demand through an immutable, paid gateway. Discipline: Videography & Film (subtitling and captions). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts captioning from a 'fixed' asset to a metered utility. Using x402 allows for granular billing where a 10-episode series only incurs costs for the specific languages a viewer actually toggles, creating a more efficient micro-economy for post-production houses. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SubGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-transcode-manifest-9-x402 Title: Manifest · x402 Theme: Videography & Film (video) · video encoding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol-level transcoding gate where video manifests are pinned and optimized only upon payment. Instead of bulk-paying to host multiple formats, creators store a master source; viewers or platforms pay 0.01 USDC to generate and retrieve the specific manifest for their resolution (4K, 1080p, HLS). Payment triggers the IPFS CID reveal of the specific encode, ensuring compute costs are instantly cleared by the consumer. Why Hedera: Video transcoding is computationally expensive. By moving from a subscription model to a pay-per-format-unlock (x402), creators can offer high-fidelity files without bearing the storage/compute overhead for non-existent audiences. Each transaction settles a Base receipt for the specific resolution manifest. Market: TAM $11.5B — Global video transcoding and cloud processing market evolving toward edge-computing and per-frame billing. | SAM $480M — Web3 streaming platforms, decentralized storage users, and independent film distributors using Base. | SOM $12M — High-end videographers and boutique studios requiring verifiable, per-view encoding manifests. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Manifest" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol-level transcoding gate where video manifests are pinned and optimized only upon payment. Instead of bulk-paying to host multiple formats, creators store a master source; viewers or platforms pay 0.01 USDC to generate and retrieve the specific manifest for their resolution (4K, 1080p, HLS). Payment triggers the IPFS CID reveal of the specific encode, ensuring compute costs are instantly cleared by the consumer. Discipline: Videography & Film (video encoding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Video transcoding is computationally expensive. By moving from a subscription model to a pay-per-format-unlock (x402), creators can offer high-fidelity files without bearing the storage/compute overhead for non-existent audiences. Each transaction settles a Base receipt for the specific resolution manifest. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Manifest" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-loopprovenance-10-x402 Title: Kinetic · x402 Theme: Videography & Film (video) · animation loops Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless library of high-fidelity animation loops where every frame-pull or export triggers a 0.01 USDC micropayment. Instead of bulky subscriptions, motion designers pay-per-loop for background renders, and AI video generators pay-per-call to ingest 'clean' motion data for training or style transfer. Why Hedera: Moving from passive metadata storage to active per-use monetization transforms assets into streaming revenue. HTS transfer allows real-time settlement for batch renders, turning loops into a liquid commodity for the agent-driven video economy. Market: TAM $4.2B — Global digital animation and stock footage distribution industry. | SAM $450M — The performance-based motion design market and automated social media content farms. | SOM $12M — Base-native creators and AI video agents requiring authenticated, high-quality training assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless library of high-fidelity animation loops where every frame-pull or export triggers a 0.01 USDC micropayment. Instead of bulky subscriptions, motion designers pay-per-loop for background renders, and AI video generators pay-per-call to ingest 'clean' motion data for training or style transfer. Discipline: Videography & Film (animation loops). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from passive metadata storage to active per-use monetization transforms assets into streaming revenue. HTS transfer allows real-time settlement for batch renders, turning loops into a liquid commodity for the agent-driven video economy. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-scriptsync-chain-11-x402 Title: WriterGuard · x402 Theme: Videography & Film (video) · script and dialogue Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency dialogue versioning and script-doctoring engine. Pay 0.01 USDC to commit a dialogue polish, branch a scene, or resolve a revision conflict via the x402 settle-and-lock model. No monthly subscriptions for film crews—just pay for the granular creative decisions that make the final cut. Every transaction provides a Base hash as an immutable proof-of-authorship for WGA/Royalty tracking. Why Hedera: Script writing is a series of thousands of micro-decisions. By making 'the edit' the atomic unit of payment, the protocol captures value from professional writer's rooms and AI script-doctoring agents who need to verify contribution history without the friction of SaaS overhead. Market: TAM $1.8B — The entertainment IP creation market, inclusive of AI-assisted dialogue generation and distribution. | SAM $250M — The global screenplay software and specialized creative collaboration niche. | SOM $12M — Independent film productions and freelance script-doctors using micropayment-integrated version control. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "WriterGuard" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency dialogue versioning and script-doctoring engine. Pay 0.01 USDC to commit a dialogue polish, branch a scene, or resolve a revision conflict via the x402 settle-and-lock model. No monthly subscriptions for film crews—just pay for the granular creative decisions that make the final cut. Every transaction provides a Base hash as an immutable proof-of-authorship for WGA/Royalty tracking. Discipline: Videography & Film (script and dialogue). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Script writing is a series of thousands of micro-decisions. By making 'the edit' the atomic unit of payment, the protocol captures value from professional writer's rooms and AI script-doctoring agents who need to verify contribution history without the friction of SaaS overhead. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "WriterGuard" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-lensprofile-archive-12-x402 Title: GlassCheck · x402 Theme: Videography & Film (video) · camera profiling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Universal LUT & IDT metadata injection. Pay per profile fetch to calibrate RAW footage against a decentralized library of physical lens characteristics. Skip the proprietary subscription; pay only for the glass you rented. Producers pay per clip to ensure automated color matching across multicam setups via x402-gated IPFS CID resolution. Why Hedera: By moving camera profiles from localized software to a pay-per-pull model, DPs and DITs can access high-end calibration data (like Cooke or Arri signatures) without expensive plugins, while contributors earn USDC every time their profile is used in a grade. Market: TAM $2.4B — Global cinema and broadcast post-production market shifting toward algorithmic and decentralized digital asset management. | SAM $180M — Independent production houses and DITs migrating to cloud-based post-production workflows. | SOM $12M — Early-adopter colorists and rental houses using automated metadata injection for Base-settled color pipelines. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GlassCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Universal LUT & IDT metadata injection. Pay per profile fetch to calibrate RAW footage against a decentralized library of physical lens characteristics. Skip the proprietary subscription; pay only for the glass you rented. Producers pay per clip to ensure automated color matching across multicam setups via x402-gated IPFS CID resolution. Discipline: Videography & Film (camera profiling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving camera profiles from localized software to a pay-per-pull model, DPs and DITs can access high-end calibration data (like Cooke or Arri signatures) without expensive plugins, while contributors earn USDC every time their profile is used in a grade. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GlassCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-assetaudit-trail-13-x402 Title: AssetAudit · x402 Theme: Videography & Film (video) · media asset management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity audit trail for media houses. Pay 0.01 USDC to immutableize a usage event or verify an IPFS content-hash signature. Every permission check and rights-transfer is a micro-transaction, building a cryptographically verifiable provenance log for film production pipelines. Why Hedera: By turning metadata logging into a metered transaction, the cost of 'auditing' is socialized across the production chain. It prevents 'metadata drift' by requiring a micro-settlement for every entry, ensuring only authenticated production data hits the chain. Market: TAM $4.2B — Global digital media asset management (DAM) and automated licensing market. | SAM $850M — Independent film productions and mid-sized post-production houses adopting on-chain provenance. | SOM $12M — Base-native creators and production DAOs requiring per-asset usage tracking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "AssetAudit" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity audit trail for media houses. Pay 0.01 USDC to immutableize a usage event or verify an IPFS content-hash signature. Every permission check and rights-transfer is a micro-transaction, building a cryptographically verifiable provenance log for film production pipelines. Discipline: Videography & Film (media asset management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning metadata logging into a metered transaction, the cost of 'auditing' is socialized across the production chain. It prevents 'metadata drift' by requiring a micro-settlement for every entry, ensuring only authenticated production data hits the chain. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "AssetAudit" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-filterforge-ipfs-14-x402 Title: FilterForge · x402 Theme: Videography & Film (video) · filter development Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized lookup and rendering protocol where colorists gate high-end LUTs and custom video filters. Pay $0.01 per frame/clip render or per download. Every pull is a direct USDC settlement to the creator's wallet, turning post-production assets into streaming revenue. Why Hedera: Traditional asset marketplaces use high-friction bulk buys. FilterForge uses x402 to meter the usage of IPFS-hosted assets, allowing editors to test premium looks for pennies before committing to a full-grade license. Market: TAM $3.2B — The global digital asset and creative subscription market. | SAM $450M — Independent video editors and motion designers using decentralized asset libraries. | SOM $12M — Early adopters in the web3 film space and creators using automated render farms. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FilterForge" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized lookup and rendering protocol where colorists gate high-end LUTs and custom video filters. Pay $0.01 per frame/clip render or per download. Every pull is a direct USDC settlement to the creator's wallet, turning post-production assets into streaming revenue. Discipline: Videography & Film (filter development). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional asset marketplaces use high-friction bulk buys. FilterForge uses x402 to meter the usage of IPFS-hosted assets, allowing editors to test premium looks for pennies before committing to a full-grade license. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FilterForge" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-clipchain-marketplace-15-x402 Title: ClipStream · x402 Theme: Videography & Film (video) · clip licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Transform raw footage into a metered utility. ClipStream allows creators to embed watermarked previews that instantly unlock for high-res download or commercial sync upon a 0.01 USDC x402 signature. No subscriptions or manual licensing agreements—just pay-per-frame usage settled instantly on Hedera. AI video generators and editors can programmatically source and pay for 'clean' training data or b-roll via the same signing primitive. Why Hedera: Traditional licensing is bottlenecked by manual contracts and high minimums. By making the 'unlock' a sub-cent primitive, we turn video libraries into high-velocity liquid assets suitable for both human editors and automated AI pipelines. Market: TAM $5.8B — The global stock video and digital rights management market. | SAM $450M — The estimated portion of the stock footage market captureable by micro-licensing and programmatic AI training access. | SOM $12M — Transaction volume from independent b-roll creators and AI video startups on Hedera testnet within the first year. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ClipStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Transform raw footage into a metered utility. ClipStream allows creators to embed watermarked previews that instantly unlock for high-res download or commercial sync upon a 0.01 USDC x402 signature. No subscriptions or manual licensing agreements—just pay-per-frame usage settled instantly on Hedera. AI video generators and editors can programmatically source and pay for 'clean' training data or b-roll via the same signing primitive. Discipline: Videography & Film (clip licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is bottlenecked by manual contracts and high minimums. By making the 'unlock' a sub-cent primitive, we turn video libraries into high-velocity liquid assets suitable for both human editors and automated AI pipelines. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ClipStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-timelinetrace-ipfs-16-x402 Title: FinalCut State · x402 Theme: Videography & Film (video) · editing timeline Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Every 'Save' is a checkpoint; every 'Open' is a settlement. Precision-timestamp your project history to Base for $0.01 per revision. Instantly share a cryptographic mirror of your Premiere or Resolve timeline with collaborators who pay-to-pull. No more 'Project_Final_v2_REAL_FINAL.prproj'—just a verifiable, recoverable stream of creative state. Why Hedera: Traditional cloud storage charges for data weight; x402 charges for the event of state-preservation. By making the 'commit' a micropayment, we turn the timeline into a permanent, paid ledger of work-in-progress, allowing editors to monetize project templates or specific scene structures as peer-to-peer d-link assets. Market: TAM $3.2B — The global film editing and digital video content creation software market. | SAM $850M — Revenue from professional post-production software and collaborative asset management tools. | SOM $12M — Independent editors and colorists using decentralized storage for project redundancy and cross-studio handoffs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FinalCut State" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Every 'Save' is a checkpoint; every 'Open' is a settlement. Precision-timestamp your project history to Base for $0.01 per revision. Instantly share a cryptographic mirror of your Premiere or Resolve timeline with collaborators who pay-to-pull. No more 'Project_Final_v2_REAL_FINAL.prproj'—just a verifiable, recoverable stream of creative state. Discipline: Videography & Film (editing timeline). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional cloud storage charges for data weight; x402 charges for the event of state-preservation. By making the 'commit' a micropayment, we turn the timeline into a permanent, paid ledger of work-in-progress, allowing editors to monetize project templates or specific scene structures as peer-to-peer d-link assets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FinalCut State" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-frameforge-ipfs-17-x402 Title: ScribeShot · x402 Theme: Videography & Film (video) · frame-specific notes Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A frame-accurate feedback protocol where every editorial note, timestamped metadata entry, or color-grade instruction is cryptographically anchored to its specific frame and stored on IPFS. The x402 primitive meters the process: $0.01 per note written, ensuring low-friction collaboration for post-production houses. Editors pay as they critique; creators unlock feedback-rich renders on-chain. Why Hedera: Standardizing video delivery via micropayments solves the 'chasing invoices' problem in creative services. By making the specific act of feedback (the note) the transactional unit, the protocol enables a pay-as-you-work model for freelance colorists and editors. Market: TAM $2.8B — The global video post-production services industry and asynchronous remote workflow market. | SAM $450M — The collaborative video editing software market, shifting toward decentralized review tools. | SOM $8.5M — Decentralized film production boutique agencies and Web3-native media DAOs requiring immutable audit trails. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScribeShot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A frame-accurate feedback protocol where every editorial note, timestamped metadata entry, or color-grade instruction is cryptographically anchored to its specific frame and stored on IPFS. The x402 primitive meters the process: $0.01 per note written, ensuring low-friction collaboration for post-production houses. Editors pay as they critique; creators unlock feedback-rich renders on-chain. Discipline: Videography & Film (frame-specific notes). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Standardizing video delivery via micropayments solves the 'chasing invoices' problem in creative services. By making the specific act of feedback (the note) the transactional unit, the protocol enables a pay-as-you-work model for freelance colorists and editors. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScribeShot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-effectchain-library-18-x402 Title: VFX-Pull · x402 Theme: Videography & Film (video) · effect presets Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-pull pro video effect stacks. Use 0.01 USDC to unlock an IPFS-hosted EffectChain manifest directly into your timeline. Creators earn on every import; editors get consistent stylization without subscription bloat. Give your footage the "high-budget" finish one node-graph at a time. Why Hedera: Traditional preset marketplaces suffer from piracy and 'all-or-nothing' pricing. x402 enables a granular, metered library where users pay only for the specific aesthetic they apply to a shot. It turns effect presets into a high-velocity digital commodity for the agent-led video editing era. Market: TAM $2.4B — The global video editing software and digital asset ecosystem. | SAM $450M — Revenue from specialized video plugin and preset marketplaces for high-end editors. | SOM $12M — Micro-transactions from the emerging class of automated AI video-generation agents and mobile-first creators. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VFX-Pull" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-pull pro video effect stacks. Use 0.01 USDC to unlock an IPFS-hosted EffectChain manifest directly into your timeline. Creators earn on every import; editors get consistent stylization without subscription bloat. Give your footage the "high-budget" finish one node-graph at a time. Discipline: Videography & Film (effect presets). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional preset marketplaces suffer from piracy and 'all-or-nothing' pricing. x402 enables a granular, metered library where users pay only for the specific aesthetic they apply to a shot. It turns effect presets into a high-velocity digital commodity for the agent-led video editing era. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VFX-Pull" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-posterchain-assets-19-x402 Title: Keyframe · x402 Theme: Videography & Film (video) · promotional imagery Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless CDN for promotional film kits where every high-resolution asset fetch is a metered x402 event. Distributors and press outlets pay 0.01 USDC per asset retrieval (Layer 3/IPFS) via HTS transfer, eliminating manual licensing friction. Producers earn real-time royalties as their official press kits are accessed by media outlets, turning promotional overhead into a self-liquidating digital distribution channel. Why Hedera: Shift from 'storage' to 'metered distribution'. By treating each asset call as a payment event, it prevents bulk scraping and ensures creators are compensated for the discovery and usage of their promotional IP. Market: TAM $2.8B — The global film marketing and promotional services industry, increasingly moving toward automated, programmatic asset delivery. | SAM $450M — The digital asset management (DAM) and middleware market specifically serving independent film distributors and advertising agencies. | SOM $12M — The niche of independent film festival circuits and boutique PR firms requiring automated billing for asset delivery. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Keyframe" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless CDN for promotional film kits where every high-resolution asset fetch is a metered x402 event. Distributors and press outlets pay 0.01 USDC per asset retrieval (Layer 3/IPFS) via HTS transfer, eliminating manual licensing friction. Producers earn real-time royalties as their official press kits are accessed by media outlets, turning promotional overhead into a self-liquidating digital distribution channel. Discipline: Videography & Film (promotional imagery). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shift from 'storage' to 'metered distribution'. By treating each asset call as a payment event, it prevents bulk scraping and ensures creators are compensated for the discovery and usage of their promotional IP. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Keyframe" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-eventsync-ledger-20-x402 Title: Cutsheet · x402 Theme: Videography & Film (video) · live editing events Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Streamline live broadcast integrity by turning edit markers into verifiable on-chain assets. $0.01 USDC per 'Cut' or 'Sync' event pinned to the ledger via the embedded wallet. Multi-cam directors and remote editors pay per-marker to ensure sub-second synchronization and immutable audit trails for high-stakes broadcasts. No subscription required; pay only for the frames you fix. Why Hedera: Micropayments solve the 'event-logging' overhead. Traditionally, syncing is a heavy cloud expense; x402 allows for granular, frame-by-frame payment for verifiability, turning a technical necessity into a per-action revenue stream for the platform. Market: TAM $3.8B — Global live event production and broadcast post-production software market. | SAM $240M — The shift toward decentralized remote production and real-time social streaming. | SOM $12M — Remote live-event VJs and crypto-native broadcast houses requiring audit logs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Cutsheet" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Streamline live broadcast integrity by turning edit markers into verifiable on-chain assets. $0.01 USDC per 'Cut' or 'Sync' event pinned to the ledger via the embedded wallet. Multi-cam directors and remote editors pay per-marker to ensure sub-second synchronization and immutable audit trails for high-stakes broadcasts. No subscription required; pay only for the frames you fix. Discipline: Videography & Film (live editing events). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Micropayments solve the 'event-logging' overhead. Traditionally, syncing is a heavy cloud expense; x402 allows for granular, frame-by-frame payment for verifiability, turning a technical necessity into a per-action revenue stream for the platform. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Cutsheet" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-transitionchain-vault-21-x402 Title: CUT · x402 Theme: Videography & Film (video) · transition effects Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A professional-grade repository for premium video transition presets where every 'Import' is a settlement. Instead of bulky subscriptions for assets you don't use, filmmakers pay 0.01 USDC to pull a specific TransitionChain JSON from IPFS directly into their NLE (Non-Linear Editor). Creators earn instantly as their effects are metered across global projects. Payment unlocks the CID and the signature required to decrypt the specific effect metadata. Why Hedera: By turning 'presets' into 'metered unlocks,' we solve the problem of asset bloat and piracy. The HTS transfer signature acts as the license key, making high-end post-production tools affordable for hobbyists and profitable for top-tier motion designers. Market: TAM $15B — The global video post-production and motion graphics market. | SAM $1.8B — The creator economy segment focusing on video editing software and digital asset marketplaces. | SOM $45M — Niche segment of crypto-native editors and AI-driven automated video generation agents using metered APIs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CUT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A professional-grade repository for premium video transition presets where every 'Import' is a settlement. Instead of bulky subscriptions for assets you don't use, filmmakers pay 0.01 USDC to pull a specific TransitionChain JSON from IPFS directly into their NLE (Non-Linear Editor). Creators earn instantly as their effects are metered across global projects. Payment unlocks the CID and the signature required to decrypt the specific effect metadata. Discipline: Videography & Film (transition effects). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'presets' into 'metered unlocks,' we solve the problem of asset bloat and piracy. The HTS transfer signature acts as the license key, making high-end post-production tools affordable for hobbyists and profitable for top-tier motion designers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CUT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-metadatamesh-ipfs-22-x402 Title: CineAnchor · x402 Theme: Videography & Film (video) · metadata management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hard-coded provenance layer for cinema. Instantly commit rich scene metadata, lens data, and continuity notes to IPFS by signing a 0.01 USDC event. Stop losing production value to messy spreadsheets; pay-per-pin ensures every frame is searchable and cryptographically anchored by the DIT in real-time. Why Hedera: The 'MetadataMesh' concept becomes a metered ledger where every 'save' or 'update' to the production metadata is an on-chain transaction. This creates a bulletproof audit trail for post-production houses. Market: TAM $2.8B — Global media asset management and archival supply chain. | SAM $420M — Decentralized storage and metadata management for independent film productions. | SOM $12M — On-set DITs and script supervisors using mobile-first metadata tools on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CineAnchor" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hard-coded provenance layer for cinema. Instantly commit rich scene metadata, lens data, and continuity notes to IPFS by signing a 0.01 USDC event. Stop losing production value to messy spreadsheets; pay-per-pin ensures every frame is searchable and cryptographically anchored by the DIT in real-time. Discipline: Videography & Film (metadata management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: The 'MetadataMesh' concept becomes a metered ledger where every 'save' or 'update' to the production metadata is an on-chain transaction. This creates a bulletproof audit trail for post-production houses. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CineAnchor" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-shotlist-ledger-23-x402 Title: SlateCheck · x402 Theme: Videography & Film (video) · production tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-update production tracker where DPs and Directors lock-in shot completions to a tamper-proof Base ledger. Every 'Mark Wrap' action triggers a 0.01 USDC micro-payment to the script supervisor's wallet, ensuring real-time metadata integrity that bond companies can audit via tx hashes. No monthly sub; you pay only for the shots you actually film. Why Hedera: Film production is plagued by chaotic version control. By turning every shot status update into a paid on-chain event, the app creates a high-integrity audit trail where the data is the payment. This eliminates 'lost' shot lists and provides a verifiable ledger for insurance and financing. Market: TAM $8.2B — The global film and video production software market shifting toward verifiable, real-time asset tracking. | SAM $450M — Independent films, commercial production houses, and high-end agency content creators using decentralized toolsets. | SOM $12M — Web3-native production crews and DAOs (like Decentralized Cinema) requiring immutable proof-of-work for milestones. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SlateCheck" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-update production tracker where DPs and Directors lock-in shot completions to a tamper-proof Base ledger. Every 'Mark Wrap' action triggers a 0.01 USDC micro-payment to the script supervisor's wallet, ensuring real-time metadata integrity that bond companies can audit via tx hashes. No monthly sub; you pay only for the shots you actually film. Discipline: Videography & Film (production tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Film production is plagued by chaotic version control. By turning every shot status update into a paid on-chain event, the app creates a high-integrity audit trail where the data is the payment. This eliminates 'lost' shot lists and provides a verifiable ledger for insurance and financing. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SlateCheck" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-seamless-clip-shares-0-x402 Title: FinalCut Flux · x402 Theme: Videography & Film (video) · collaborative editing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Render-on-demand collaboration. Pay to push high-res edit states or pull frame-accurate feedback. x402 handles the 'handshake' between editors: every sequence sync is a signed micropayment, eliminating credit systems for pure pay-per-sync velocity. Why Hedera: By turning 'sharing' into a paid primitive, you eliminate spam and ensure the facilitator (compute/storage) is compensated per action. It treats the edit timeline as a metered API, allowing global teams to collaborate without monthly subscriptions. Market: TAM $2.1B — The global video editing software market shifting toward cloud-native, real-time collaboration. | SAM $140M — Professional freelance editors and boutique agencies using cloud-based collaborative workflows. | SOM $8.5M — Decentralized content houses and Web3 media collectives using Base for fast, cheap asset settlement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FinalCut Flux" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Render-on-demand collaboration. Pay to push high-res edit states or pull frame-accurate feedback. x402 handles the 'handshake' between editors: every sequence sync is a signed micropayment, eliminating credit systems for pure pay-per-sync velocity. Discipline: Videography & Film (collaborative editing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'sharing' into a paid primitive, you eliminate spam and ensure the facilitator (compute/storage) is compensated per action. It treats the edit timeline as a metered API, allowing global teams to collaborate without monthly subscriptions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FinalCut Flux" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-tokenized-cut-approval-1-x402 Title: FinalCut Finality · x402 Theme: Videography & Film (video) · edit review Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A frame-accurate review player where every 'Approve' or 'Request Revision' action is a finalized onchain event. Clients sign a 0.01 USDC authorization to timestamp a cut's status, eliminating scope creep by making each feedback cycle a metered, immutable contract. No more 'just one more change' without a transaction. Why Hedera: By turning review milestones into micropayments, the app creates a financial trail that prevents revision fatigue. It forces intentionality; the cost isn't the hurdle, the signature is the commitment. Market: TAM $5.8B — The global video editing software and cloud collaboration market. | SAM $450M — The collaborative video editing and post-production software market. | SOM $12M — Web3-native production houses and DAOs requiring verifiable sign-offs for treasury releases. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FinalCut Finality" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A frame-accurate review player where every 'Approve' or 'Request Revision' action is a finalized onchain event. Clients sign a 0.01 USDC authorization to timestamp a cut's status, eliminating scope creep by making each feedback cycle a metered, immutable contract. No more 'just one more change' without a transaction. Discipline: Videography & Film (edit review). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning review milestones into micropayments, the app creates a financial trail that prevents revision fatigue. It forces intentionality; the cost isn't the hurdle, the signature is the commitment. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FinalCut Finality" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-clip-provenance-ledger-2-x402 Title: TRUEFRAME · x402 Theme: Videography & Film (video) · authenticity tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A 'Verify-to-View' protocol for raw footage. Deepfakes are free; truth costs human-scale cents. Documentary filmmakers and news agencies seal raw files behind an x402 gate. Consumers pay 0.01 USDC to cryptographically verify the camera metadata, GPS origin, and edit history, receiving a Hedera transaction id as a permanent receipt of truth. Audit trails are no longer a luxury; they are a metered public utility. Why Hedera: By shifting provenance from a 'background feature' to a 'pay-per-verification' model, we monetize trust. Each verification call funds the storage of the ledger and compensates the original videographer for maintaining the source's integrity. Market: TAM $8.5B — The global anti-misinformation and digital content authentication market. | SAM $1.2B — The professional journalism and digital forensics market requiring instant, verifiable source audits. | SOM $15M — Independent documentary filmmakers and citizen journalists using Base for transparent footage distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TRUEFRAME" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A 'Verify-to-View' protocol for raw footage. Deepfakes are free; truth costs human-scale cents. Documentary filmmakers and news agencies seal raw files behind an x402 gate. Consumers pay 0.01 USDC to cryptographically verify the camera metadata, GPS origin, and edit history, receiving a Hedera transaction id as a permanent receipt of truth. Audit trails are no longer a luxury; they are a metered public utility. Discipline: Videography & Film (authenticity tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting provenance from a 'background feature' to a 'pay-per-verification' model, we monetize trust. Each verification call funds the storage of the ledger and compensates the original videographer for maintaining the source's integrity. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TRUEFRAME" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-sponsored-effects-marketplace-3-x402 Title: FX-Flow · x402 Theme: Videography & Film (video) · visual effects trading Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency exchange where boutique visual effects (VFX) and custom shaders are streamed to editors via pay-per-render micropayments. Instead of restrictive annual licenses, VFX artists monetize their proprietary nodes by charging 0.01 USDC every time a frame is processed or a shader is applied in the browser. Editors pay exactly for what they use, bypassing high upfront costs, while creators receive instant settlement for every 'hit' of their effect. Why Hedera: Traditional VFX licensing is bloated with high barriers to entry; x402 allows for 'metered rendering' where the payment primitive enables granular, per-use monetization of digital assets. Market: TAM $8.5B — The global visual effects and post-production software market migrating toward decentralized, distributed computing. | SAM $450M — The independent creator economy and boutique post-production houses transitioning to pay-as-you-go cloud tools. | SOM $12M — Early adopters in the Base and Farcaster video-sharing ecosystems using on-chain compositing tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FX-Flow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency exchange where boutique visual effects (VFX) and custom shaders are streamed to editors via pay-per-render micropayments. Instead of restrictive annual licenses, VFX artists monetize their proprietary nodes by charging 0.01 USDC every time a frame is processed or a shader is applied in the browser. Editors pay exactly for what they use, bypassing high upfront costs, while creators receive instant settlement for every 'hit' of their effect. Discipline: Videography & Film (visual effects trading). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional VFX licensing is bloated with high barriers to entry; x402 allows for 'metered rendering' where the payment primitive enables granular, per-use monetization of digital assets. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FX-Flow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-gasless-frame-tokens-4-x402 Title: STILL · x402 Theme: Videography & Film (video) · NFT video frames Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity frame extraction tool for cinema buffs and collectors. Pay 0.01 USDC to rip a pristine, cryptographically signed raw frame from any uploaded video. Every 'Capture' prints the frame to a collection while triggering a micro-royalty to the original filmmaker. No minting gas, just a per-click fee for the vanity of the still. Why Hedera: By shifting from 'gasless NFTs' to 'metered extraction,' you monetize the utility of high-res capture rather than the speculation of the token. HTS transfer allows fans to build a gallery one frame at a time without the friction of a full minting UI. Market: TAM $8.5B — The global video production and NFT media distribution market. | SAM $1.2B — The digital cinema collectibles and fan-art licensing market. | SOM $18M — Targeted spend from niche film communities and superfans on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STILL" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity frame extraction tool for cinema buffs and collectors. Pay 0.01 USDC to rip a pristine, cryptographically signed raw frame from any uploaded video. Every 'Capture' prints the frame to a collection while triggering a micro-royalty to the original filmmaker. No minting gas, just a per-click fee for the vanity of the still. Discipline: Videography & Film (NFT video frames). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'gasless NFTs' to 'metered extraction,' you monetize the utility of high-res capture rather than the speculation of the token. HTS transfer allows fans to build a gallery one frame at a time without the friction of a full minting UI. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STILL" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-instant-sponsor-rewards-5-x402 Title: CUTSHARE · x402 Theme: Videography & Film (video) · creator monetization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view-frame primitive for high-value video cuts. Creators embed 'Sponsor Walls' within footage; viewers sign a 0.01 USDC HTS transfer message to instantly unlock extended scenes, 4K renders, or sponsor-subsidized rebate codes. No gas, no bridge, just a signature for a frame-frame. Why Hedera: Current monetization relies on bulky subscriptions or platform-wide ads. x402 enables 'micro-sponsorships' where the fan pays a cent to access a hidden link, which in turn triggers a smart-contract referral for the sponsor—creating a high-velocity feedback loop for ROI. Market: TAM $250B — The global digital video advertising and creator economy market. | SAM $1.2B — The total addressable market of independent videographers and social media creators using Web3 monetization tools. | SOM $85M — Focused on Farcaster and Lens power-users utilizing Frames for direct video sales. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CUTSHARE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view-frame primitive for high-value video cuts. Creators embed 'Sponsor Walls' within footage; viewers sign a 0.01 USDC HTS transfer message to instantly unlock extended scenes, 4K renders, or sponsor-subsidized rebate codes. No gas, no bridge, just a signature for a frame-frame. Discipline: Videography & Film (creator monetization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current monetization relies on bulky subscriptions or platform-wide ads. x402 enables 'micro-sponsorships' where the fan pays a cent to access a hidden link, which in turn triggers a smart-contract referral for the sponsor—creating a high-velocity feedback loop for ROI. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CUTSHARE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-collaborative-scene-ledger-6-x402 Title: ScriptLock · x402 Theme: Videography & Film (video) · scene co-creation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time scene-building engine where every narrative choice, lighting tweak, or camera angle requires a 0.01 USDC commit. Creators buy into the 'Master Ledger' to lock their vision into the final edit. Film students and indie crews use it to resolve creative stalemates: if you want the final say on the cut, you pay the protocol to mint the change. The Facilitator settles the creative sequence on Hedera, ensuring the director always has a verifiable, paid-for trail of contribution for backend royalty splits. Why Hedera: Traditional film credits are opaque; by making every creative decision a micropayment, the 'ledger' becomes a financial source of truth for fair residuals and IP ownership. Market: TAM $1.2B — The global film production software and creative collaboration market. | SAM $45M — Collaborative pre-production and digital storyboarding tools for indie filmmakers. | SOM $1.8M — Web3-native film collectives and DAO-funded short film productions on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScriptLock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time scene-building engine where every narrative choice, lighting tweak, or camera angle requires a 0.01 USDC commit. Creators buy into the 'Master Ledger' to lock their vision into the final edit. Film students and indie crews use it to resolve creative stalemates: if you want the final say on the cut, you pay the protocol to mint the change. The Facilitator settles the creative sequence on Hedera, ensuring the director always has a verifiable, paid-for trail of contribution for backend royalty splits. Discipline: Videography & Film (scene co-creation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional film credits are opaque; by making every creative decision a micropayment, the 'ledger' becomes a financial source of truth for fair residuals and IP ownership. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScriptLock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-script-to-screen-sync-7-x402 Title: FINAL CUT · x402 Theme: Videography & Film (video) · production alignment Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A terminal for high-stakes production where every script revision and EDL (Edit Decision List) update is cryptographically timestamped and synced. Pay 0.01 USDC per 'Commit' to lock global alignment, ensuring editors, directors, and VFX houses never work on outdated versions. No gas, just signed intent to sync. Why Hedera: Video production suffers from 'version hell.' By turning the 'Sync' button into a micropayment event, you create a verifiable audit trail of project progression and accountability that gas-heavy transactions would stifle. Market: TAM $2.8B — Global film and television production software market transitioning to decentralized collaboration. | SAM $400M — Professional post-production houses and distributed creative agencies using Base. | SOM $12M — Remote-first indie feature film teams and ad agencies requiring real-time sync verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FINAL CUT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A terminal for high-stakes production where every script revision and EDL (Edit Decision List) update is cryptographically timestamped and synced. Pay 0.01 USDC per 'Commit' to lock global alignment, ensuring editors, directors, and VFX houses never work on outdated versions. No gas, just signed intent to sync. Discipline: Videography & Film (production alignment). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Video production suffers from 'version hell.' By turning the 'Sync' button into a micropayment event, you create a verifiable audit trail of project progression and accountability that gas-heavy transactions would stifle. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FINAL CUT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-gasless-licensing-hub-8-x402 Title: CLIPCHAIN · x402 Theme: Videography & Film (video) · rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-velocity license layer for B-roll. Instead of bulk subscriptions, creators pay 0.01 USDC to instantly sign a commercial usage waiver for a specific clip. The app returns a Hedera transaction id acting as an immutable, time-stamped proof of rights, minted via HTS transfer without the friction of gas. Why Hedera: Shifts rights management from a 'subscription burden' to a 'granular utility.' By metering the license at the per-clip level, we capture the long-tail of micro-content creators who only need one-off clearances. Market: TAM $8.4B — The global stock media and licensing market transitioning to automated, machine-readable smart contracts. | SAM $2.8B — The addressable market of independent content creators and social media editors requiring cleared assets. | SOM $15M — Reaching the initial tier of decentralized film productions and AI-video generator tools seeking automated rights settlement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CLIPCHAIN" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-velocity license layer for B-roll. Instead of bulk subscriptions, creators pay 0.01 USDC to instantly sign a commercial usage waiver for a specific clip. The app returns a Hedera transaction id acting as an immutable, time-stamped proof of rights, minted via HTS transfer without the friction of gas. Discipline: Videography & Film (rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts rights management from a 'subscription burden' to a 'granular utility.' By metering the license at the per-clip level, we capture the long-tail of micro-content creators who only need one-off clearances. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CLIPCHAIN" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-verified-creator-ids-9-x402 Title: Proof of Set · x402 Theme: Videography & Film (video) · identity verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Film sets are high-trust environments. Every crew check-in, equipment rental, or NDA signature now requires a 0.01 USDC micro-attestation via x402. Production houses use this to build instant, verifiable reputation ledgers where every interaction is a paid, signed proof of professional identity, eliminating credential fraud in the gig economy. Why Hedera: Shifts the model from a 'free' utility to a high-velocity 'proof-of-presence' protocol. By making each verification a paid micro-transaction, it creates a real-world cost for reputation, deterring sybil attacks and syphon-proof talent pools. Market: TAM $45B — Global identity verification and background check industry. | SAM $1.4B — Independent film production and specialized crew staffing globally. | SOM $22M — Base-native creator DAOs and onchain film financing platforms. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proof of Set" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Film sets are high-trust environments. Every crew check-in, equipment rental, or NDA signature now requires a 0.01 USDC micro-attestation via x402. Production houses use this to build instant, verifiable reputation ledgers where every interaction is a paid, signed proof of professional identity, eliminating credential fraud in the gig economy. Discipline: Videography & Film (identity verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from a 'free' utility to a high-velocity 'proof-of-presence' protocol. By making each verification a paid micro-transaction, it creates a real-world cost for reputation, deterring sybil attacks and syphon-proof talent pools. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proof of Set" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-sponsored-review-tokens-10-x402 Title: DirectCut · x402 Theme: Videography & Film (video) · audience feedback Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn your audience into a high-signal focus group. Creators gate high-resolution feedback submissions; viewers pay 0.01 USDC to have their timestamps pinned, reviewed, and algorithmically prioritized in the creator's dashboard via the x402 primitive. Pay-per-review ensures zero spam and instant settlement. Why Hedera: By moving feedback from free-form (low quality) to micropayment-metered (high quality), you create a financial signal for attention. x402 eliminates gas friction while allowing creators to monetize the labor of processing audience critiques. Market: TAM $14B — Global digital video production and audience analytics market. | SAM $850M — The addressable tier of independent YouTube and Nebula creators using premium feedback tools. | SOM $12M — Initial capture of Base-native videographers and crypto-native creative collectives. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DirectCut" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn your audience into a high-signal focus group. Creators gate high-resolution feedback submissions; viewers pay 0.01 USDC to have their timestamps pinned, reviewed, and algorithmically prioritized in the creator's dashboard via the x402 primitive. Pay-per-review ensures zero spam and instant settlement. Discipline: Videography & Film (audience feedback). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving feedback from free-form (low quality) to micropayment-metered (high quality), you create a financial signal for attention. x402 eliminates gas friction while allowing creators to monetize the labor of processing audience critiques. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DirectCut" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-chain-backed-storyboards-11-x402 Title: DirectorShot · x402 Theme: Videography & Film (video) · previsualization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time previz engine where every directorial decision is a micro-transaction. Pay 0.01 USDC to unlock a professional camera move, trigger a lighting state change, or export a frame to the production hub. No subscriptions—just high-fidelity pre-production metered by creative output. Why Hedera: Moving from 'storing data' to 'paying for studio-grade tools' turns the tool into a professional utility. x402 eliminates the friction of pro-tier subscriptions for independent cinematographers, allowing them to pay only for the shots they actually draft. Market: TAM $18B — Global film and video production software market shifting toward modular, cloud-native services. | SAM $1.2B — High-growth segment of indie filmmakers and boutique agencies utilizing digital previz workflows. | SOM $45M — Early adopters in the Base and Farcaster creative ecosystems requiring instant-settlement production tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DirectorShot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time previz engine where every directorial decision is a micro-transaction. Pay 0.01 USDC to unlock a professional camera move, trigger a lighting state change, or export a frame to the production hub. No subscriptions—just high-fidelity pre-production metered by creative output. Discipline: Videography & Film (previsualization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'storing data' to 'paying for studio-grade tools' turns the tool into a professional utility. x402 eliminates the friction of pro-tier subscriptions for independent cinematographers, allowing them to pay only for the shots they actually draft. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DirectorShot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-gasless-release-commits-12-x402 Title: CutPoint · x402 Theme: Videography & Film (video) · version control Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Final Cut onchain. Video editors pay 0.01 USDC per immutable version commit to a decentralized production timeline. Every 'Render & Push' triggers a signed HTS transfer transfer, creating a cryptographically verifiable audit trail of creative changes. Producers pay to unlock high-res source links from specific commit hashes. No more 'Final_v2_REAL_FINAL.mp4' confusion; just a metered, provable history of the grade. Why Hedera: By moving video versioning from 'free/gasless' to 'micropayment-gated,' we solve the spam/storage bloat issue while providing a clear revenue model for the infrastructure. The payment acts as the timestamping mechanism, making every edit-commit a formal, paid entry in the film's ledger. Market: TAM $4.2B — The global cloud video editing and asset management market moving toward decentralized storage. | SAM $480M — The collaborative post-production and VFX market requiring verified version tracking. | SOM $12M — Independent documentary and commercial film editors utilizing Base for transparent client hand-offs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CutPoint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Final Cut onchain. Video editors pay 0.01 USDC per immutable version commit to a decentralized production timeline. Every 'Render & Push' triggers a signed HTS transfer transfer, creating a cryptographically verifiable audit trail of creative changes. Producers pay to unlock high-res source links from specific commit hashes. No more 'Final_v2_REAL_FINAL.mp4' confusion; just a metered, provable history of the grade. Discipline: Videography & Film (version control). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving video versioning from 'free/gasless' to 'micropayment-gated,' we solve the spam/storage bloat issue while providing a clear revenue model for the infrastructure. The payment acts as the timestamping mechanism, making every edit-commit a formal, paid entry in the film's ledger. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CutPoint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-tokenized-b-roll-access-13-x402 Title: FrameDrop · x402 Theme: Videography & Film (video) · footage licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Footage is no longer bound by bulky monthly subscriptions. FrameDrop implements frame-level metering where production houses and creators pay 0.01 USDC per clip preview or high-res download. Using HTS transfer signed authorizations, editors unlock raw B-roll assets instantly from their NLE, settling on-chain without gas. It turns static libraries into high-velocity liquidity pools for cinematography. Why Hedera: By shifting from 'all-you-can-eat' licenses to 0.01 USDC per-clip unlocks, we lower the barrier for indie creators while creating a continuous micro-revenue stream for videographers. The x402 primitive acts as the digital notary for the usage rights. Market: TAM $5.2B - The global video content creation and stock media market. | SAM $850M - The stock footage and digital licensing sub-sector accessible via crypto-native rails. | SOM $12M - Boutique colorists, social media editors, and AI-video model trainers requiring granular data sets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameDrop" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Footage is no longer bound by bulky monthly subscriptions. FrameDrop implements frame-level metering where production houses and creators pay 0.01 USDC per clip preview or high-res download. Using HTS transfer signed authorizations, editors unlock raw B-roll assets instantly from their NLE, settling on-chain without gas. It turns static libraries into high-velocity liquidity pools for cinematography. Discipline: Videography & Film (footage licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'all-you-can-eat' licenses to 0.01 USDC per-clip unlocks, we lower the barrier for indie creators while creating a continuous micro-revenue stream for videographers. The x402 primitive acts as the digital notary for the usage rights. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameDrop" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-sponsored-collab-invites-14-x402 Title: CallSheet · x402 Theme: Videography & Film (video) · team management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Streamline high-stakes production tiers. Producers pay a 1-cent micropayment to 'Auth-Call' specialized crew (Colorists, Editors, DPs) into a private project environment. Each invite acts as a cryptographically signed contract entry, eliminating email back-and-forth and ghosting. No 'invite-all' spam; every seat on the call sheet is a paid, onchain commitment ensuring professional intent. Why Hedera: By attaching a fee to the invite primitive, we filter noise and turn team management into a metered service. It shifts 'collaboration' from a passive social state to an active, settled transaction. Market: TAM $950M — The global media production management software market, increasingly moving toward granular, gig-based workforces. | SAM $45M — The niche market of independent production houses and agencies requiring verifiable, secure freelancer onboarding. | SOM $2.8M — First-year capture of decentralized film collectives and remote post-production houses on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CallSheet" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Streamline high-stakes production tiers. Producers pay a 1-cent micropayment to 'Auth-Call' specialized crew (Colorists, Editors, DPs) into a private project environment. Each invite acts as a cryptographically signed contract entry, eliminating email back-and-forth and ghosting. No 'invite-all' spam; every seat on the call sheet is a paid, onchain commitment ensuring professional intent. Discipline: Videography & Film (team management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By attaching a fee to the invite primitive, we filter noise and turn team management into a metered service. It shifts 'collaboration' from a passive social state to an active, settled transaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CallSheet" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-privy-clip-bounties-15-x402 Title: B-Roll Raw · x402 Theme: Videography & Film (video) · crowdsourced footage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular stock-footage marketplace where every preview-to-hi-res upgrade is a micro-transaction. Filmmakers post 'Scenes' with open bounties; contributors sign off HTS transfer permits to 'Check-in' raw clips. The protocol handles fractional settlement from the production house's wallet to the contributor's the embedded wallet address instantly upon ingest. No subscriptions, just $0.01 per second of raw B-roll reviewed. Why Hedera: Shifts from 'bounties' (bulk, slow) to 'metered ingestion' (granular, instant). By pricing the submission/review process at the micro-level, it filters noise and professionalizes the crowdsourcing pipeline via Base's low fees. Market: TAM $4.2B — The total creator economy expenditure on licensing and content acquisition. | SAM $850M — The global stock footage and crowdsourced media market. | SOM $45M — Independent documentary and social media production houses utilizing decentralized contributor networks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "B-Roll Raw" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular stock-footage marketplace where every preview-to-hi-res upgrade is a micro-transaction. Filmmakers post 'Scenes' with open bounties; contributors sign off HTS transfer permits to 'Check-in' raw clips. The protocol handles fractional settlement from the production house's wallet to the contributor's the embedded wallet address instantly upon ingest. No subscriptions, just $0.01 per second of raw B-roll reviewed. Discipline: Videography & Film (crowdsourced footage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from 'bounties' (bulk, slow) to 'metered ingestion' (granular, instant). By pricing the submission/review process at the micro-level, it filters noise and professionalizes the crowdsourcing pipeline via Base's low fees. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "B-Roll Raw" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-chain-stamped-edits-16-x402 Title: FinalCut Proxy · x402 Theme: Videography & Film (video) · edit authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Verify the integrity of a film edit by committing its cryptographic fingerprint to Base for 0.01 USDC. Every version export is a payable event, creating a granular audit trail that proves exactly when a cut was finalized. No gas management; just sign, pay a penny, and anchor the truth. Why Hedera: Moving from 'gasless' to 'micropayment-native' turns authenticity into a metered service. Each 'stamp' is a discrete transaction—making it ideal for production houses monitoring version control or freelancers providing proof of work for milestones. Market: TAM $1.2B — The global digital asset rights and post-production software market. | SAM $45M — Professional editors and colorists using frame-accurate timestamping for client delivery. | SOM $2.1M — Independent videographers and TikTok creators securing original content against AI-clones. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FinalCut Proxy" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Verify the integrity of a film edit by committing its cryptographic fingerprint to Base for 0.01 USDC. Every version export is a payable event, creating a granular audit trail that proves exactly when a cut was finalized. No gas management; just sign, pay a penny, and anchor the truth. Discipline: Videography & Film (edit authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'gasless' to 'micropayment-native' turns authenticity into a metered service. Each 'stamp' is a discrete transaction—making it ideal for production houses monitoring version control or freelancers providing proof of work for milestones. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FinalCut Proxy" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-gasless-asset-sync-17-x402 Title: SyncNode · x402 Theme: Videography & Film (video) · media syncing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: High-speed media orchestration for high-stakes production. Pay 0.01 USDC to sync a single 4K proxy or metadata packet across a global contributor network. Eliminate subscription bloat by paying strictly per-asset-handshake. Producers settle the queue via HTS transfer, ensuring every frame is logged on Hedera with a permanent transaction hash for chain-of-custody audit trails. Why Hedera: Traditional sync tools use opaque monthly subs; x402 turns synchronization into a metered utility. By charging per-sync-event, it captures value from power users (studios) while remaining free-to-play for indies, using the Hedera transaction id as a definitive 'receipt' for version control. Market: TAM $3.8B — The global cloud media management and collaborative workflow market moving toward agentic, pay-per-buffer models. | SAM $420M — The Decentralized Physical Infrastructure (DePIN) and film production software market adopting per-use micro-billing. | SOM $18M — Independent film crews and boutique post-production houses on Hedera requiring verifiable asset handoffs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SyncNode" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT High-speed media orchestration for high-stakes production. Pay 0.01 USDC to sync a single 4K proxy or metadata packet across a global contributor network. Eliminate subscription bloat by paying strictly per-asset-handshake. Producers settle the queue via HTS transfer, ensuring every frame is logged on Hedera with a permanent transaction hash for chain-of-custody audit trails. Discipline: Videography & Film (media syncing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional sync tools use opaque monthly subs; x402 turns synchronization into a metered utility. By charging per-sync-event, it captures value from power users (studios) while remaining free-to-play for indies, using the Hedera transaction id as a definitive 'receipt' for version control. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SyncNode" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-token-gate-premieres-18-x402 Title: SPROCKET · x402 Theme: Videography & Film (video) · exclusive screenings Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-minute micropayment engine for cinematic premieres. Viewers stream films by streaming USDC—0.01 USDC triggers every 60 seconds of playback via HTS transfer. No subscriptions, no upfront tickets, no ads. Users pay exactly for the time they watch, with the Hedera transaction id serving as a verified 'View Receipt' for participation in post-film director Q&As. Why Hedera: Traditional token-gating is binary; x402 enables 'Pay-as-you-Watch' granularity. This removes the friction of high ticket prices while ensuring filmmakers are paid for every second of attention. Market: TAM $25B — The global PVOD (Premium Video on Demand) and digital streaming market. | SAM $850M — The independent film distribution and micro-rental digital market. | SOM $12M — Web3-native film festivals and niche creator premieres using Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SPROCKET" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-minute micropayment engine for cinematic premieres. Viewers stream films by streaming USDC—0.01 USDC triggers every 60 seconds of playback via HTS transfer. No subscriptions, no upfront tickets, no ads. Users pay exactly for the time they watch, with the Hedera transaction id serving as a verified 'View Receipt' for participation in post-film director Q&As. Discipline: Videography & Film (exclusive screenings). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional token-gating is binary; x402 enables 'Pay-as-you-Watch' granularity. This removes the friction of high ticket prices while ensuring filmmakers are paid for every second of attention. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SPROCKET" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-sponsored-clip-challenges-19-x402 Title: FrameSync · x402 Theme: Videography & Film (video) · user engagement Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A challenge engine where brands post high-stakes brief templates, and creators pay 0.01 USDC to submit their reel for instant AI-grading and automated reward distribution. Payment serves as an anti-spam filter and entry fee, with winners receiving split-payouts gathered from the submission pool. No gas, just signed intent. Why Hedera: By moving from 'free' to a 1-cent micropayment, we solve the bot-spam problem in video contests. The x402 primitive ensures that every entry is a skin-in-the-game commitment, allowing for trustless, instant sponsorship payouts without manual oversight. Market: TAM $110B — The global short-form video creator economy and ad-spend market. | SAM $850M — The performance-based influencer marketing spend on Hedera and Ethereum L2s. | SOM $12M — Micro-targeted video challenge entries via Hedera testnet mobile integrators. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameSync" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A challenge engine where brands post high-stakes brief templates, and creators pay 0.01 USDC to submit their reel for instant AI-grading and automated reward distribution. Payment serves as an anti-spam filter and entry fee, with winners receiving split-payouts gathered from the submission pool. No gas, just signed intent. Discipline: Videography & Film (user engagement). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'free' to a 1-cent micropayment, we solve the bot-spam problem in video contests. The x402 primitive ensures that every entry is a skin-in-the-game commitment, allowing for trustless, instant sponsorship payouts without manual oversight. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameSync" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-gasless-comment-tokens-20-x402 Title: Director’s Cut · x402 Theme: Videography & Film (video) · community feedback Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-signal grading layer for filmmakers. Viewers stake 0.01 USDC via x402 to submit 'Director’s Notes'—high-priority feedback that creators are paid to review. Use micro-payments to eliminate bot noise and reward legitimate cinematic critique. Why Hedera: By turning the comment into a paid micropayment, you flip the incentive: feedback becomes a revenue stream for the creator and a proof-of-skin-in-the-game for the viewer. x402 ensures the transaction is instant and frictionless. Market: TAM $2.4B — The global creator economy economy seeking spam-free engagement tools. | SAM $850M — The addressable market of independent creators using Patreon/Kojack for audience monetization. | SOM $22M — Early adopter film-tech communities and film festival feedback loops on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Director’s Cut" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-signal grading layer for filmmakers. Viewers stake 0.01 USDC via x402 to submit 'Director’s Notes'—high-priority feedback that creators are paid to review. Use micro-payments to eliminate bot noise and reward legitimate cinematic critique. Discipline: Videography & Film (community feedback). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the comment into a paid micropayment, you flip the incentive: feedback becomes a revenue stream for the creator and a proof-of-skin-in-the-game for the viewer. x402 ensures the transaction is instant and frictionless. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Director’s Cut" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-chain-linked-shot-lists-21-x402 Title: CLAPBOARD · x402 Theme: Videography & Film (video) · production planning Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A production planning tool where every shot entry, revision, and checklist completion is a micro-transaction. Directors 'buy' the lock on a scene sequence, and DPs are paid 0.01 USDC per frame confirmation. Instead of a free-tier graveyard, it's a pay-as-you-shoot environment where the production budget is streamed directly into the metadata of the shot list, ensuring verified progress for remote executive producers. Why Hedera: Production planning suffers from version bloat and lack of accountability. By making every shot update an x402 event, you create a high-fidelity audit trail where every cent spent corresponds to a tangible planning action, eliminating coordination friction through financial micro-incentives. Market: TAM $3.2B — The global film production management software industry shifting toward verifiable onchain workflows. | SAM $450M — The independent film and commercial production software market looking for real-time cost-tracking tools. | SOM $18M — Web3-native creative agencies and decentralized film collectives using Base/HashPack. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CLAPBOARD" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A production planning tool where every shot entry, revision, and checklist completion is a micro-transaction. Directors 'buy' the lock on a scene sequence, and DPs are paid 0.01 USDC per frame confirmation. Instead of a free-tier graveyard, it's a pay-as-you-shoot environment where the production budget is streamed directly into the metadata of the shot list, ensuring verified progress for remote executive producers. Discipline: Videography & Film (production planning). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Production planning suffers from version bloat and lack of accountability. By making every shot update an x402 event, you create a high-fidelity audit trail where every cent spent corresponds to a tangible planning action, eliminating coordination friction through financial micro-incentives. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CLAPBOARD" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-sponsored-render-credits-22-x402 Title: RENDERSTORM · x402 Theme: Videography & Film (video) · compute resource sharing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Eliminate idle GPU time by turning rendering power into a metered API. Producers pay 0.01 USDC per frame or compute-second directly to the node provider's wallet via HTS transfer. No subscriptions or bulky credit packs—just pure, granular settlement for every frame rendered. Why Hedera: Shifts rendering from a legacy credit-buy model to a live-metered utility. x402 handles the high-frequency micro-settlement required for frame-by-frame rendering without the friction of manual approvals. Market: TAM $8.5B — The global cloud rendering and visual effects (VFX) market. | SAM $420M — Decentralized rendering networks (DePIN) and independent post-production houses. | SOM $12M — Indie filmmakers and motion designers requiring ad-hoc burst capacity on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "RENDERSTORM" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Eliminate idle GPU time by turning rendering power into a metered API. Producers pay 0.01 USDC per frame or compute-second directly to the node provider's wallet via HTS transfer. No subscriptions or bulky credit packs—just pure, granular settlement for every frame rendered. Discipline: Videography & Film (compute resource sharing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts rendering from a legacy credit-buy model to a live-metered utility. x402 handles the high-frequency micro-settlement required for frame-by-frame rendering without the friction of manual approvals. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "RENDERSTORM" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-gasless-frame-annotations-23-x402 Title: FrameInk · x402 Theme: Videography & Film (video) · video feedback Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An asynchronous critique layer for cinema production. Creative directors and clients drop millisecond-precise annotations on video frames. Each frame-specific 'ink' or 'note' is signed and settled via x402, ensuring frame-accurate feedback is verifiable, permanent, and paid for on-chain. No monthly subscriptions for post-production houses—only pay for the frames you fix. Why Hedera: By shifting from a subscription model to a pay-per-annotation model (0.01 USDC), the platform incentivizes high-density, high-quality feedback while removing the barrier for freelance editors who only need occasional professional review tools. Market: TAM $1.2B — The total addressable market for professional video post-production services and collaboration software. | SAM $140M — The global cloud-based video editing and collaborative review market. | SOM $8.5M — Independent colorists, VFX boutiques, and commercial editors on Hedera utilizing per-frame micro-billing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameInk" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An asynchronous critique layer for cinema production. Creative directors and clients drop millisecond-precise annotations on video frames. Each frame-specific 'ink' or 'note' is signed and settled via x402, ensuring frame-accurate feedback is verifiable, permanent, and paid for on-chain. No monthly subscriptions for post-production houses—only pay for the frames you fix. Discipline: Videography & Film (video feedback). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a subscription model to a pay-per-annotation model (0.01 USDC), the platform incentivizes high-density, high-quality feedback while removing the barrier for freelance editors who only need occasional professional review tools. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameInk" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-tokenized-music-sync-24-x402 Title: SYNCUP · x402 Theme: Videography & Film (video) · audio licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Stem-level audio licensing. High-end music stems are metered per render. Creators don't buy a subscription; they pay 0.01 USDC to unlock the high-fidelity master of a track for a single export via the embedded wallet-signed HTS transfer. Perfect for TikTok editors and indie filmmakers who need hit-quality audio without the $50/mo overhead. Music becomes a streaming utility, not a static asset. Why Hedera: By turning music licensing into a sub-penny per-export event, you unlock the long-tail of micro-content creators who can't afford traditional sync fees. x402 handles the 'proof of payment' as the license itself, recorded on Hedera. Market: TAM $2.8B — Global digital music synchronization and audio stock licensing market转向 programmable micro-rights. | SAM $450M — The independent creator economy and micro-influencer music licensing segment. | SOM $12M — Short-form video editors on mobile platforms requiring instant, compliant sync rights for high-end tracks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SYNCUP" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Stem-level audio licensing. High-end music stems are metered per render. Creators don't buy a subscription; they pay 0.01 USDC to unlock the high-fidelity master of a track for a single export via the embedded wallet-signed HTS transfer. Perfect for TikTok editors and indie filmmakers who need hit-quality audio without the $50/mo overhead. Music becomes a streaming utility, not a static asset. Discipline: Videography & Film (audio licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning music licensing into a sub-penny per-export event, you unlock the long-tail of micro-content creators who can't afford traditional sync fees. x402 handles the 'proof of payment' as the license itself, recorded on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SYNCUP" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-framelock-provenance-0-x402 Title: FrameProof · x402 Theme: Videography & Film (video) · shot authentication Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographically signed watermark and metadata stamp for RAW footage. Every time a high-res master is previewed or retrieved by a production house, a 0.01 USDC x402 payment executes. This creates a perpetual, verifiable chain of custody where the payment hash *is* the proof of licensing. Editors pay per clip unlock; creators earn per frame accessed. Why Hedera: Traditional watermarking is easily cropped; x402 turns the 'request for high-res' into an immutable toll gate. It replaces legal contracts with a meter that settles ownership proofs in real-time on Hedera. Market: TAM $4.2B — The global digital asset management and video forensics market for professional media. | SAM $450M — Independent videographers and boutique ad agencies requiring decentralized digital rights management (DRM). | SOM $12M — High-end stock footage contributors and freelance documentary editors on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographically signed watermark and metadata stamp for RAW footage. Every time a high-res master is previewed or retrieved by a production house, a 0.01 USDC x402 payment executes. This creates a perpetual, verifiable chain of custody where the payment hash *is* the proof of licensing. Editors pay per clip unlock; creators earn per frame accessed. Discipline: Videography & Film (shot authentication). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional watermarking is easily cropped; x402 turns the 'request for high-res' into an immutable toll gate. It replaces legal contracts with a meter that settles ownership proofs in real-time on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-colorgrade-ledger-1-x402 Title: ChromaMeter · x402 Theme: Videography & Film (video) · color grading Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A distributed LUT registry where cinematographers pay 0.01 USDC to unlock professional grade-stacks. Every 'Apply' action is a micro-transaction that settles on-chain, creating a transparent revenue stream for colorists while providing filmmakers with verifiable, production-ready aesthetic profiles. Pay per frame-match or per LUT-pull via signed HTS transfer authorization. Why Hedera: Shifts color grading from a static software license to a metered 'aesthetic-as-a-service' model, rewarding technical precision at the individual clip level. Market: TAM $4.2B — The global film and video post-production market evolving toward decentralized asset distribution. | SAM $850M — The addressable market for independent directors, DPs, and digital editors utilizing granular, high-end post-production tools. | SOM $12M — Target capture of high-volume commercial production houses and social content agencies on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ChromaMeter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A distributed LUT registry where cinematographers pay 0.01 USDC to unlock professional grade-stacks. Every 'Apply' action is a micro-transaction that settles on-chain, creating a transparent revenue stream for colorists while providing filmmakers with verifiable, production-ready aesthetic profiles. Pay per frame-match or per LUT-pull via signed HTS transfer authorization. Discipline: Videography & Film (color grading). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts color grading from a static software license to a metered 'aesthetic-as-a-service' model, rewarding technical precision at the individual clip level. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ChromaMeter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-clipchain-remix-2-x402 Title: SpliceNode · x402 Theme: Videography & Film (video) · video remixing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless video sequencer where every 'Cut', 'Filter', or 'Layer' applied to a source clip is a 0.01 USDC transaction. Users pay per remix operation, which automatically triggers a micro-rebate to the original creator's wallet. Perfect for high-volume meme template generation and AI-assisted supercuts where attribution is enforced by the payment flow itself. No subscription, just pay for the frames you modify. Why Hedera: Traditional provenance is passive; x402 makes it active. By metering the remixing process (pay-per-edit), we create a sustainable 'royalty-at-source' model that doesn't rely on complex legal contracts, only HTS transfer signatures. Market: TAM $3.2B — The global video editing software market, increasingly shifting toward collaborative and AI-driven cloud workflows. | SAM $450M — The creative professional and high-end hobbyist market utilizing cloud-based editing suites and asset libraries. | SOM $12M — Early adopters in the web3 creator economy and developers building automated 'Remix Bots' that generate thousands of variants daily. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SpliceNode" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless video sequencer where every 'Cut', 'Filter', or 'Layer' applied to a source clip is a 0.01 USDC transaction. Users pay per remix operation, which automatically triggers a micro-rebate to the original creator's wallet. Perfect for high-volume meme template generation and AI-assisted supercuts where attribution is enforced by the payment flow itself. No subscription, just pay for the frames you modify. Discipline: Videography & Film (video remixing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional provenance is passive; x402 makes it active. By metering the remixing process (pay-per-edit), we create a sustainable 'royalty-at-source' model that doesn't rely on complex legal contracts, only HTS transfer signatures. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SpliceNode" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-scenereveal-rights-3-x402 Title: SceneReveal · x402 Theme: Videography & Film (video) · scene licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Scene-level licensing as a granular utility. Metadata and high-res downloads are metered via x402, allowing editors to pay $0.01 to preview a raw clip or $0.10 to unlock a commercial-use license hash instantly. No bulk subs—just pay for the frames you cut into your timeline. Why Hedera: By turning licensing into a micropayment primitive, we remove the friction of legal back-and-forth. Every 0.01 USDC call acts as an automated settlement between the editor and the cinematographer, verified by a Hedera transaction id attached to the asset's URI. Market: TAM $4.2B — The global stock footage and digital rights management market. | SAM $450M — Revenue from production houses and independent stock footage marketplaces transitioning to automated licensing. | SOM $12M — Initial capture of indie documentary filmmakers and TikTok/Reels content aggregators. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SceneReveal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Scene-level licensing as a granular utility. Metadata and high-res downloads are metered via x402, allowing editors to pay $0.01 to preview a raw clip or $0.10 to unlock a commercial-use license hash instantly. No bulk subs—just pay for the frames you cut into your timeline. Discipline: Videography & Film (scene licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning licensing into a micropayment primitive, we remove the friction of legal back-and-forth. Every 0.01 USDC call acts as an automated settlement between the editor and the cinematographer, verified by a Hedera transaction id attached to the asset's URI. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SceneReveal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-storyboard-stamp-4-x402 Title: DirectorCut · x402 Theme: Videography & Film (video) · previsualization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity previsualization engine where every frame generation or storyboard 'stamp' is a discrete 0.01 USDC micro-transaction. Instead of subscriptions, directors pay for the exact volume of creative vision they execute. Each stamp triggers an HTS transfer transfer that anchors the scene's metadata and timestamp to Base, creating an immutable, paid audit trail of creative evolution from script to screen. Why Hedera: Moving from NFT minting (high friction) to x402 micropayments (low friction) turns storyboarding into a utility-metered service. It solves the 'orphaned creative' problem by ensuring every iteration is financially anchored and cryptographically verified for less than the cost of a physical sticky note. Market: TAM $12.5B — The total creative production and digital asset management sector encompassing film, gaming, and spatial computing. | SAM $1.4B — The global animation and VFX pre-production market transitioning to real-time, cloud-based collaborative tools. | SOM $85M — Independent filmmakers, ad agencies, and boutique pre-viz houses moving away from high-overhead SaaS subscriptions to pay-per-frame models. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DirectorCut" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity previsualization engine where every frame generation or storyboard 'stamp' is a discrete 0.01 USDC micro-transaction. Instead of subscriptions, directors pay for the exact volume of creative vision they execute. Each stamp triggers an HTS transfer transfer that anchors the scene's metadata and timestamp to Base, creating an immutable, paid audit trail of creative evolution from script to screen. Discipline: Videography & Film (previsualization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from NFT minting (high friction) to x402 micropayments (low friction) turns storyboarding into a utility-metered service. It solves the 'orphaned creative' problem by ensuring every iteration is financially anchored and cryptographically verified for less than the cost of a physical sticky note. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DirectorCut" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-edittrace-ledger-5-x402 Title: FinalCut Ledger · x402 Theme: Videography & Film (video) · editing history Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An immutable version-control layer for high-end post-production sequences. Every action—trim, grade, or transition—is cryptographically signed and stored by paying 0.01 USDC. This creates a forensic audit trail for multi-editor workflows, ensuring attribution and preventing unauthorized alterations in the final cut. Payment is the proof-of-work for every frame adjustment. Why Hedera: By making every edit a micro-transaction, we eliminate 'phantom' changes and link the editor's wallet directly to the creative evolution of the file. x402 handles the high-frequency signing needed for non-linear editing (NLE) logs. Market: TAM $3.5B — The global film and video production software market shifting toward decentralized collaboration. | SAM $450M — Post-production houses and boutique agencies moving toward transparent, auditable project management. | SOM $12M — Freelance colorists and editors on Hedera requiring verified proof of work for remote clients. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FinalCut Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An immutable version-control layer for high-end post-production sequences. Every action—trim, grade, or transition—is cryptographically signed and stored by paying 0.01 USDC. This creates a forensic audit trail for multi-editor workflows, ensuring attribution and preventing unauthorized alterations in the final cut. Payment is the proof-of-work for every frame adjustment. Discipline: Videography & Film (editing history). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making every edit a micro-transaction, we eliminate 'phantom' changes and link the editor's wallet directly to the creative evolution of the file. x402 handles the high-frequency signing needed for non-linear editing (NLE) logs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FinalCut Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-clipmint-archival-6-x402 Title: DeepCut · x402 Theme: Videography & Film (video) · archival footage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view engine for high-resolution archival masters. Instead of subscription silos or clunky NFT minting, filmmakers pay 0.01 USDC to instantly unlock and download water-mark free historic b-roll. Each playback or download triggers a direct micro-settlement to the original archive holder via HTS transfer, making the price-per-clip small enough for hobbyists but scalable for documentary production suites. Why Hedera: Shifts archival access from a high-friction licensing process to a frictionless, metered utility. By using x402, the payment becomes the 'access key,' turning deep-catalog footage into a liquid, pay-as-you-go API for editors. Market: TAM $4.2B — The global stock footage and digital asset management market, increasingly pivoting toward automated micro-licensing. | SAM $850M — The documentary and stock footage licensing sector specifically for digital-first creators and indie filmmakers. | SOM $12M — Initial capture of the Base-native creator economy and decentralized production studios requiring quick, verifiable archival assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DeepCut" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view engine for high-resolution archival masters. Instead of subscription silos or clunky NFT minting, filmmakers pay 0.01 USDC to instantly unlock and download water-mark free historic b-roll. Each playback or download triggers a direct micro-settlement to the original archive holder via HTS transfer, making the price-per-clip small enough for hobbyists but scalable for documentary production suites. Discipline: Videography & Film (archival footage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts archival access from a high-friction licensing process to a frictionless, metered utility. By using x402, the payment becomes the 'access key,' turning deep-catalog footage into a liquid, pay-as-you-go API for editors. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DeepCut" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-scriptproof-token-7-x402 Title: ScriptHash · x402 Theme: Videography & Film (video) · script authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-scene digital notary for screenwriters. Instead of bulk copyrighting, every revision, character arc, or dialogue polish is timestamped and secured on-chain. Producers pay $0.01 USDC to unlock an 'Authenticity Proof' before reading, and writers pay $0.01 USDC to commit a hash of their latest draft, creating an immutable paper trail that kills plagiarism in the crib. Why Hedera: By moving from 'NFT minting' to 'per-save/per-view micropayments,' the app becomes a high-frequency utility. It shifts the burden of proof from legal battles to cryptographic verification. Market: TAM $2.4B — Global IP protection and legal verification market for creative writing. | SAM $450M — Independent screenwriters, ad agency creatives, and script doctors globally. | SOM $18M — Early-stage script submission platforms and decentralized film funding communities. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScriptHash" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-scene digital notary for screenwriters. Instead of bulk copyrighting, every revision, character arc, or dialogue polish is timestamped and secured on-chain. Producers pay $0.01 USDC to unlock an 'Authenticity Proof' before reading, and writers pay $0.01 USDC to commit a hash of their latest draft, creating an immutable paper trail that kills plagiarism in the crib. Discipline: Videography & Film (script authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'NFT minting' to 'per-save/per-view micropayments,' the app becomes a high-frequency utility. It shifts the burden of proof from legal battles to cryptographic verification. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScriptHash" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-moodboard-mint-8-x402 Title: DirectorCut · x402 Theme: Videography & Film (video) · visual concepting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A secure visual sandbox for directors. Pay 0.01 USDC to append high-res frames, AI-generated storyboards, or color palettes to a collaborative session. Each interaction is a Base transaction that hard-codes attribution and creative sign-off between the DP, Director, and Client. No monthly subs—only pay for the frames you commit to the board. Why Hedera: Visual concepting is iterative and fragmented. By turning every board addition into a micropayment, the tool functions as a 'creative ledger,' ensuring directors are paid for ideation and every asset's lineage is cryptographically verified on-chain. Market: TAM $45B — Global movie and video production market. | SAM $1.2B — High-end commercial production and indie film conceptualization overhead. | SOM $15M — On-chain pre-production for decentralized film labs and AI-video creators. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DirectorCut" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A secure visual sandbox for directors. Pay 0.01 USDC to append high-res frames, AI-generated storyboards, or color palettes to a collaborative session. Each interaction is a Base transaction that hard-codes attribution and creative sign-off between the DP, Director, and Client. No monthly subs—only pay for the frames you commit to the board. Discipline: Videography & Film (visual concepting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Visual concepting is iterative and fragmented. By turning every board addition into a micropayment, the tool functions as a 'creative ledger,' ensuring directors are paid for ideation and every asset's lineage is cryptographically verified on-chain. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DirectorCut" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-soundsync-provenance-9-x402 Title: SonicBond · x402 Theme: Videography & Film (video) · audio synchronization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to cryptographically align a high-fidelity audio track to a video frame and generate a permanent 'Sync-Lock' hash. Every alignment call pays the sound engineer or the AI model instantly, creating a verifiable chain of custody for ADR and sound design. Payment is the proof of synchronization; no settlement, no sync. Why Hedera: By turning 'synchronization' into a metered x402 transaction, you move from static NFT minting to a functional service. The micropayment acts as the trigger for the alignment algorithm, ensuring creators are paid per edit cycle rather than a flat, easily pirated fee. Market: TAM $3.2B — The global digital audio workstation (DAW) and video editing software market. | SAM $450M — The market for independent film post-production services and micro-budget content creators. | SOM $12M — AI-driven ADR tools and automated audio-match services on Hedera for social media editors. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SonicBond" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to cryptographically align a high-fidelity audio track to a video frame and generate a permanent 'Sync-Lock' hash. Every alignment call pays the sound engineer or the AI model instantly, creating a verifiable chain of custody for ADR and sound design. Payment is the proof of synchronization; no settlement, no sync. Discipline: Videography & Film (audio synchronization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'synchronization' into a metered x402 transaction, you move from static NFT minting to a functional service. The micropayment acts as the trigger for the alignment algorithm, ensuring creators are paid per edit cycle rather than a flat, easily pirated fee. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SonicBond" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-titletrack-tokens-10-x402 Title: Kinetic · x402 Theme: Videography & Film (video) · title animation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless motion engine where editors and AI video agents pay per render. Instead of buying a template, you pay 0.01 USDC to generate a high-end, physics-based title animation via a single API call. Perfect for automated YouTube workflows or programmatic ad generation where quality motion shouldn't require a subscription. Why Hedera: Moving from static NFTs to a consumption-based render model treats title animation as a utility. Each signature authorizes a server-side render, turning premium motion design into a liquid commodity for the agentic video economy. Market: TAM $4.2B — The total addressable market for automated cloud-based video production and generative media. | SAM $850M — The global motion graphics and automated video editing market. | SOM $12M — The niche for high-frequency, programmatic title generation for content creators and automated news channels on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless motion engine where editors and AI video agents pay per render. Instead of buying a template, you pay 0.01 USDC to generate a high-end, physics-based title animation via a single API call. Perfect for automated YouTube workflows or programmatic ad generation where quality motion shouldn't require a subscription. Discipline: Videography & Film (title animation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from static NFTs to a consumption-based render model treats title animation as a utility. Each signature authorizes a server-side render, turning premium motion design into a liquid commodity for the agentic video economy. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-lensflare-ledger-11-x402 Title: FLARE · x402 Theme: Videography & Film (video) · visual effects Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: 0.01 USDC per frame-render to inject cryptographically signed, creator-owned anamorphic flares. Stop using generic assets; pay the artist directly for every export. The HTS transfer signature authorizes the compositing engine to pull the high-fidelity effect metadata while instantly settling a royalty to the original cinematographer. Why Hedera: By shifting from a one-time 'minting' model to a per-use 'rendering' model, creators earn passive income for every production that utilizes their specific optical signatures, while editors only pay for what they actually use in the final cut. Market: TAM $15B — The global cinematic post-production and digital asset licensing market. | SAM $850M — The visual effects (VFX) market for independent film and high-end commercial production. | SOM $12M — Micro-licensing for individual LUTs and VFX presets on decentralized marketplaces. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FLARE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT 0.01 USDC per frame-render to inject cryptographically signed, creator-owned anamorphic flares. Stop using generic assets; pay the artist directly for every export. The HTS transfer signature authorizes the compositing engine to pull the high-fidelity effect metadata while instantly settling a royalty to the original cinematographer. Discipline: Videography & Film (visual effects). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a one-time 'minting' model to a per-use 'rendering' model, creators earn passive income for every production that utilizes their specific optical signatures, while editors only pay for what they actually use in the final cut. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FLARE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-cutsequence-proof-12-x402 Title: CutLog · x402 Theme: Videography & Film (video) · editing sequence Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A programmable NLE primitive where every 'Render' or 'Export' action requires a signed 0.01 USDC micro-settlement to commit the sequence to the ledger. Instead of clunky NFT minting, CutLog meters version control: creators pay per version-hash, and studios pay per sequence retrieval. It turns the edit timeline into a granular, pay-per-state audit trail, ensuring every cut is timestamped and paid for, preventing 'work-for-hire' theft by gating the final EDL behind high-resolution settlement. Why Hedera: By shifting from 'NFT ownership' to 'pay-per-commit,' the app creates a continuous value stream for version control and cryptographic proof of work without the friction of large gas fees or minting ceremonies. Market: TAM $12.5B — The global film and video production workflow industry transitioning to decentralized collaborative tools. | SAM $850M — The collaborative cloud-based video editing and post-production software market. | SOM $42M — Professional freelance editors and boutique agencies using Base for real-time version-control and asset-gating. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CutLog" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A programmable NLE primitive where every 'Render' or 'Export' action requires a signed 0.01 USDC micro-settlement to commit the sequence to the ledger. Instead of clunky NFT minting, CutLog meters version control: creators pay per version-hash, and studios pay per sequence retrieval. It turns the edit timeline into a granular, pay-per-state audit trail, ensuring every cut is timestamped and paid for, preventing 'work-for-hire' theft by gating the final EDL behind high-resolution settlement. Discipline: Videography & Film (editing sequence). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'NFT ownership' to 'pay-per-commit,' the app creates a continuous value stream for version control and cryptographic proof of work without the friction of large gas fees or minting ceremonies. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CutLog" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-filterforge-token-13-x402 Title: Lumina · x402 Theme: Videography & Film (video) · video filters Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-frame cinematic processing. FilterForge provides a library of high-fidelity, GPU-accelerated video shaders where every render call requires a 0.01 USDC authorization. Instead of selling a static preset that gets leaked, creators earn per-second of footage processed. The x402 primitive handles the HTS transfer signature for every batch of frames, enabling a 'pay-as-you-render' model for indie filmmakers and mobile editors. Why Hedera: Traditional NFT filter sales suffer from 'buy once, leak everywhere' issues. By moving the compute behind an x402-gated API, the creator is paid for the actual value provided (the render), and users only pay for what they use rather than an expensive upfront license. Market: TAM $3.8B — The global digital cinematography and visual effects software market. | SAM $450M — The mobile video editing app market and indie post-production houses transitioning to pay-as-you-go cloud rendering. | SOM $12M — Web3-native creators and short-form video editors using Base-integrated tools for social media content. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Lumina" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-frame cinematic processing. FilterForge provides a library of high-fidelity, GPU-accelerated video shaders where every render call requires a 0.01 USDC authorization. Instead of selling a static preset that gets leaked, creators earn per-second of footage processed. The x402 primitive handles the HTS transfer signature for every batch of frames, enabling a 'pay-as-you-render' model for indie filmmakers and mobile editors. Discipline: Videography & Film (video filters). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT filter sales suffer from 'buy once, leak everywhere' issues. By moving the compute behind an x402-gated API, the creator is paid for the actual value provided (the render), and users only pay for what they use rather than an expensive upfront license. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Lumina" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-vlogproof-mint-14-x402 Title: VlogProof · x402 Theme: Videography & Film (video) · content authenticity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An instant proof-of-authenticity engine for mobile videographers. Instead of bulk minting, creators pay 0.01 USDC to cryptographically timestamp and anchor a video hash to Base at the moment of capture. This generates a verifiable 'Proof of Origin' badge, turning every shot into a tamper-proof asset that prevents unauthorized AI cloning or deepfake re-uploads. Payment is the trigger for truth. Why Hedera: By moving from high-friction NFT minting to a 0.01 USDC x402 micro-transaction, the app scales with the volume of raw footage. It transforms metadata anchoring into a high-utility commodity for journalists and high-stakes creators. Market: TAM $4.2B — Global digital content security and anti-deepfake forensic markets. | SAM $850M — Independent content creators, investigative journalists, and mobile vloggers requiring instant verification. | SOM $12M — Base-native mobile creators and citizen journalists in high-misinformation regions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VlogProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An instant proof-of-authenticity engine for mobile videographers. Instead of bulk minting, creators pay 0.01 USDC to cryptographically timestamp and anchor a video hash to Base at the moment of capture. This generates a verifiable 'Proof of Origin' badge, turning every shot into a tamper-proof asset that prevents unauthorized AI cloning or deepfake re-uploads. Payment is the trigger for truth. Discipline: Videography & Film (content authenticity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from high-friction NFT minting to a 0.01 USDC x402 micro-transaction, the app scales with the volume of raw footage. It transforms metadata anchoring into a high-utility commodity for journalists and high-stakes creators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VlogProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-motionmap-token-15-x402 Title: GIMBALFLUX · x402 Theme: Videography & Film (video) · motion tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: 0.01 USDC per frame to extract world-space coordinates from raw footage. Producers pay to unlock high-fidelity .json tracking data for VFX pipelines. Motion artists earn instantly as their tracked solves are called by AI-rotoscoping agents or compositors, replacing manual license renegotiations with real-time micropayment access. Why Hedera: Shifts the value from 'static proof' (NFT) to 'dynamic access' (pay-per-use data). By metering the tracking data via HTS transfer, the footage becomes a liquid API for VFX artists. Market: TAM $3.8B — Global post-production and professional video editing software market. | SAM $450M — Modern VFX budgets allocated to rotoscoping and match-moving outsourcing. | SOM $12M — Independent VFX houses and solo motion designers using micro-tasked tracking data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GIMBALFLUX" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT 0.01 USDC per frame to extract world-space coordinates from raw footage. Producers pay to unlock high-fidelity .json tracking data for VFX pipelines. Motion artists earn instantly as their tracked solves are called by AI-rotoscoping agents or compositors, replacing manual license renegotiations with real-time micropayment access. Discipline: Videography & Film (motion tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the value from 'static proof' (NFT) to 'dynamic access' (pay-per-use data). By metering the tracking data via HTS transfer, the footage becomes a liquid API for VFX artists. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GIMBALFLUX" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-overlay-origin-16-x402 Title: FrameRate · x402 Theme: Videography & Film (video) · graphic overlays Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity asset library for editors where every graphic overlay—from 4K film grains to lower-thirds—is metered. Stop buying $300 bundles you only use 2% of. Instead, pay 0.01 USDC per asset import directly into your timeline via HTS transfer. Each micro-license is a Hedera transaction id, ensuring creators get paid for every single frame-set pulled into a project. Why Hedera: Shifts the value from 'static ownership' (NFT) to 'utility-based consumption' (x402). By making the cost of one overlay negligible (0.01 USDC), you eliminate piracy friction while building a massive, sustainable stream for graphic artists. Market: TAM $3.2B — Global stock media and motion graphics licensing market transitioning to micro-metered distribution. | SAM $450M — The creative asset marketplace for independent videographers and social media creators. | SOM $12M — Early adopters in the 'Edit-on-the-Go' mobile app space and high-frequency TikTok/Reels editors. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameRate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity asset library for editors where every graphic overlay—from 4K film grains to lower-thirds—is metered. Stop buying $300 bundles you only use 2% of. Instead, pay 0.01 USDC per asset import directly into your timeline via HTS transfer. Each micro-license is a Hedera transaction id, ensuring creators get paid for every single frame-set pulled into a project. Discipline: Videography & Film (graphic overlays). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the value from 'static ownership' (NFT) to 'utility-based consumption' (x402). By making the cost of one overlay negligible (0.01 USDC), you eliminate piracy friction while building a massive, sustainable stream for graphic artists. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameRate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-subtitle-signet-17-x402 Title: OpenScript · x402 Theme: Videography & Film (video) · captioning Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Burn accessibility into the chain. Pay-per-frame to generate, verify, or hard-code high-fidelity subtitles. Creators sign HTS transfer permits to pull captions from a global vault, while AI agents pay x402 fees to 'read' video metadata for search indexing. Settlement guarantees the translator or original captioner is paid every time a viewer toggles a language layer. Why Hedera: Reframes captions from a static NFT asset to a utility-metered stream. x402 allows for per-second or per-request billing, making sub-layer access a fluid economic activity rather than a one-time purchase. Market: TAM $4.2B — Global video accessibility and translation services. | SAM $950M — The localization and dubbing market transitioning to automated, micro-settled AI workflows. | SOM $12M — Independent creators and decentralized streaming protocols requiring automated, low-latency captioning settlement on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "OpenScript" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Burn accessibility into the chain. Pay-per-frame to generate, verify, or hard-code high-fidelity subtitles. Creators sign HTS transfer permits to pull captions from a global vault, while AI agents pay x402 fees to 'read' video metadata for search indexing. Settlement guarantees the translator or original captioner is paid every time a viewer toggles a language layer. Discipline: Videography & Film (captioning). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Reframes captions from a static NFT asset to a utility-metered stream. x402 allows for per-second or per-request billing, making sub-layer access a fluid economic activity rather than a one-time purchase. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "OpenScript" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-trailertrace-mint-18-x402 Title: Cuts · x402 Theme: Videography & Film (video) · promotional cuts Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: TrailerTrace operates as a decentralized CDN for film marketing where viewers or distributors pay 0.01 USDC per stream-unlock. Instead of high-friction subscriptions, studios meter high-fidelity trailer access and press-kit downloads. Each x402 payment triggers an immediate HTS transfer transfer, returning a Hedera transaction id that acts as a cryptographically verifiable 'proof-of-view' and release-date timestamp. For creators, it's a 'pay-per-frame' distribution model that eliminates ad-revenue middlemen. Why Hedera: By moving from a one-time 'minting' event to a 'pay-per-access' model, the studio generates granular revenue from every single engagement while establishing an immutable audit trail of who accessed the footage and when. Market: TAM $4.2B — The global movie trailer and promotional content industry transitioning to digital-first licensing. | SAM $850M — The global digital film marketing and electronic press kit (EPK) distribution market. | SOM $12M — Independent film festivals and agencies using Base for transparent promotional asset tracking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Cuts" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT TrailerTrace operates as a decentralized CDN for film marketing where viewers or distributors pay 0.01 USDC per stream-unlock. Instead of high-friction subscriptions, studios meter high-fidelity trailer access and press-kit downloads. Each x402 payment triggers an immediate HTS transfer transfer, returning a Hedera transaction id that acts as a cryptographically verifiable 'proof-of-view' and release-date timestamp. For creators, it's a 'pay-per-frame' distribution model that eliminates ad-revenue middlemen. Discipline: Videography & Film (promotional cuts). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a one-time 'minting' event to a 'pay-per-access' model, the studio generates granular revenue from every single engagement while establishing an immutable audit trail of who accessed the footage and when. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Cuts" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-gift-provenance-19-x402 Title: LoopPay · x402 Theme: Videography & Film (video) · animated GIFs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-speed micro-licensing engine for viral loops. Instead of static NFTs, GIFt turns every loop into a metered asset. Embedders pay 0.01 USDC via x402 to 'unlock' high-res, watermark-free playback for 24 hours. Creators earn every time a meme goes viral on a third-party platform. Why Hedera: Traditional NFT minting is too high-friction for meme culture. By turning the playback itself into a pay-per-use primitive, we align the incentive of the creator with the velocity of the viral loop. x402 allows for 'frictionless consumption' where the viewer or the hosting platform pays for the bandwidth and rights in a single click-to-sign transaction. Market: TAM $2.8B — The global digital stickers, memes, and short-form looping content market across messaging and social apps. | SAM $450M — Revenue potential from premium GIF integrations, professional social media management tools, and ad-tech 'verified loop' spend. | SOM $12M — Reaching power-users on Farcaster and Lens who seek to monetize high-quality original motion design. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LoopPay" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-speed micro-licensing engine for viral loops. Instead of static NFTs, GIFt turns every loop into a metered asset. Embedders pay 0.01 USDC via x402 to 'unlock' high-res, watermark-free playback for 24 hours. Creators earn every time a meme goes viral on a third-party platform. Discipline: Videography & Film (animated GIFs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT minting is too high-friction for meme culture. By turning the playback itself into a pay-per-use primitive, we align the incentive of the creator with the velocity of the viral loop. x402 allows for 'frictionless consumption' where the viewer or the hosting platform pays for the bandwidth and rights in a single click-to-sign transaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LoopPay" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-dronefootage-token-20-x402 Title: Vertigo · x402 Theme: Videography & Film (video) · aerial videography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-view licensing protocol for high-resolution aerial cinematography. Instead of bulk subscriptions, creators gate 4K master files behind 0.01 USDC x402 calls. Every frame request or 'unlock' triggers an instant micropayment directly to the pilot, automating the licensing of B-roll for news, film, and social media. Why Hedera: Traditional stock footage sites take 30-70% cuts. x402 allows pilots to monetize raw clips directly at the metadata layer. By making access friction-less (0.01 USDC), it discourages piracy and enables micro-licensing for independent creators who only need a 5-second establishing shot. Market: TAM $10.5B — Commercial drone services and digital media licensing economy. | SAM $1.2B — The global stock footage market, specifically the segment moving toward short-form video and independent content creation. | SOM $85M — Independent drone cinematographers and news stringers using decentralized storage (IPFS/Arweave) to host licensed 4K footage. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vertigo" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-view licensing protocol for high-resolution aerial cinematography. Instead of bulk subscriptions, creators gate 4K master files behind 0.01 USDC x402 calls. Every frame request or 'unlock' triggers an instant micropayment directly to the pilot, automating the licensing of B-roll for news, film, and social media. Discipline: Videography & Film (aerial videography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional stock footage sites take 30-70% cuts. x402 allows pilots to monetize raw clips directly at the metadata layer. By making access friction-less (0.01 USDC), it discourages piracy and enables micro-licensing for independent creators who only need a 5-second establishing shot. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vertigo" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-timelapse-token-21-x402 Title: ChronoProof · x402 Theme: Videography & Film (video) · time-lapse videography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-frame decentralized rendering engine for high-resolution time-lapses. Instead of bulk licensing, the app uses x402 to meter the 'Development' of RAW sequence files. Users pay 0.01 USDC to unlock an AI-enhanced frame-interpolation or to verify a single timestamped frame against a professional raw-data log. Perfect for news agencies needing verified visual chronologies or creators selling granular usage rights one second at a time. Why Hedera: Moving from a static NFT mint to a metered 'verification and processing' model creates continuous utility. The value shifts from 'owning' the video to 'witnessing/rendering' the authentic development of the shot, preventing AI-generated spoofing in documentary filmmaking. Market: TAM $15B — The global digital video surveillance, stock media, and forensic visual analysis market. | SAM $2.4B — The licensing and post-production software market for professional videographers and stock footage houses. | SOM $180M — Independent documentarians, news verification desks, and architectural progress trackers requiring micro-authenticated visual logs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ChronoProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-frame decentralized rendering engine for high-resolution time-lapses. Instead of bulk licensing, the app uses x402 to meter the 'Development' of RAW sequence files. Users pay 0.01 USDC to unlock an AI-enhanced frame-interpolation or to verify a single timestamped frame against a professional raw-data log. Perfect for news agencies needing verified visual chronologies or creators selling granular usage rights one second at a time. Discipline: Videography & Film (time-lapse videography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a static NFT mint to a metered 'verification and processing' model creates continuous utility. The value shifts from 'owning' the video to 'witnessing/rendering' the authentic development of the shot, preventing AI-generated spoofing in documentary filmmaking. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ChronoProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-multicam-mint-22-x402 Title: SyncStream · x402 Theme: Videography & Film (video) · multi-camera editing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time synchronization engine for multi-angle footage. Instead of subscription bloat, pay 0.01 USDC per 'Sync-Burst' to align audio waveforms or video metadata across unlimited tracks. The app settles a Base tx for every finalized sync, anchoring the edit signature to the chain for verifiable creative provenance. Create a rough cut, pay for the compute, and export the EDL instantly. Why Hedera: By shifting from 'NFT minting' to 'pay-per-sync,' we turn a high-friction asset creation event into a low-friction utility. x402 allows editors to meter the expensive computational work of audio-to-video alignment, ensuring builders are paid for the processing power used per clip rather than a flat monthly fee. Market: TAM $3.2B — The global video editing software and cloud-rendering market. | SAM $420M — Professional freelance editors and independent production houses leveraging cloud-based collaborative tools. | SOM $18M — High-frequency social media content creators and event videographers requiring instant, verifiable multicam syncs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SyncStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time synchronization engine for multi-angle footage. Instead of subscription bloat, pay 0.01 USDC per 'Sync-Burst' to align audio waveforms or video metadata across unlimited tracks. The app settles a Base tx for every finalized sync, anchoring the edit signature to the chain for verifiable creative provenance. Create a rough cut, pay for the compute, and export the EDL instantly. Discipline: Videography & Film (multi-camera editing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'NFT minting' to 'pay-per-sync,' we turn a high-friction asset creation event into a low-friction utility. x402 allows editors to meter the expensive computational work of audio-to-video alignment, ensuring builders are paid for the processing power used per clip rather than a flat monthly fee. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SyncStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-tutorialtoken-vault-23-x402 Title: FrameRate · x402 Theme: Videography & Film (video) · educational content Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A gated learning protocol where student agents and human creators exchange USDC for modular skill acquisition. Instead of bulky subscriptions, users pay 0.01 USDC to stream 'Knowledge Atoms'—high-fidelity video segments that verify delivery via on-chain proof. Creators earn instant liquidity for every frame processed by a learner. Why Hedera: The legacy 'NFT as certificate' model is static. Pay-per-view micropayments turn educational content into a streaming utility where the price-to-value ratio is perfectly aligned. It eliminates the friction of high-cost courses by metering the curriculum. Market: TAM $250B — Global e-learning and creator economy market. | SAM $1.2B — Professional continuing education and upskilling markets on Hedera. | SOM $85M — On-chain technical tutorials and developer onboarding content. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameRate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A gated learning protocol where student agents and human creators exchange USDC for modular skill acquisition. Instead of bulky subscriptions, users pay 0.01 USDC to stream 'Knowledge Atoms'—high-fidelity video segments that verify delivery via on-chain proof. Creators earn instant liquidity for every frame processed by a learner. Discipline: Videography & Film (educational content). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: The legacy 'NFT as certificate' model is static. Pay-per-view micropayments turn educational content into a streaming utility where the price-to-value ratio is perfectly aligned. It eliminates the friction of high-cost courses by metering the curriculum. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameRate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA video-360proof-token-24-x402 Title: OmniCert · x402 Theme: Videography & Film (video) · 360-degree videography Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — verifiable truth for immersive media. 360Proof allows creators to cryptographically sign 360° video segments at the moment of capture. Consumers and news agencies pay a 0.01 USDC micro-fee to unlock the 'Chain-of-Custody' metadata and original resolution source, ensuring the footage hasn't been AI-altered or cropped to misrepresent the scene. Every frame-check is a direct settlement to the cinematographer's Magic Link email sign-in. Why Hedera: By commodifying the 'proof' rather than the asset itself, we turn cinematography into a real-time verification service. Pay-per-verify is a more sustainable model for journalists than a one-time NFT sale. Market: TAM $4.2B — The global 360-degree camera and immersive content market by 2028. | SAM $850M — The addressable market for verifiable news media and anti-deepfake forensic tools. | SOM $12M — Independent immersive journalists and documentary filmmakers using Base for low-cost attribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "OmniCert" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — verifiable truth for immersive media. 360Proof allows creators to cryptographically sign 360° video segments at the moment of capture. Consumers and news agencies pay a 0.01 USDC micro-fee to unlock the 'Chain-of-Custody' metadata and original resolution source, ensuring the footage hasn't been AI-altered or cropped to misrepresent the scene. Every frame-check is a direct settlement to the cinematographer's Magic Link email sign-in. Discipline: Videography & Film (360-degree videography). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By commodifying the 'proof' rather than the asset itself, we turn cinematography into a real-time verification service. Pay-per-verify is a more sustainable model for journalists than a one-time NFT sale. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "OmniCert" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ============================================================================== THEME · Visual Art painters, illustrators, generative artists, gallerists ============================================================================== ------------------------------------------------------------------------------ IDEA visual-art-provenance-palette-0-x402 Title: HardTrace · x402 Theme: Visual Art (visual-art) · art ownership tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An on-chain verification layer where creators and galleries pay 0.01 USDC to append or verify high-fidelity provenance events. Trade paper trails for a micro-paid, immutable ledger of truth. Why Hedera: By moving provenance from a static database to a pay-per-update model, we eliminate spam and turn the history of an object into a revenue-generating asset for the protocol. Every scan and every transfer signature becomes a micro-transactional proof-of-status. Market: TAM $5.2B — Global art market verification and title management services. | SAM $400M — High-end fine art and digital collectibles market requiring non-custodial audit trails. | SOM $12M — Emerging digital-physical (phygital) artists and boutique galleries on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "HardTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An on-chain verification layer where creators and galleries pay 0.01 USDC to append or verify high-fidelity provenance events. Trade paper trails for a micro-paid, immutable ledger of truth. Discipline: Visual Art (art ownership tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving provenance from a static database to a pay-per-update model, we eliminate spam and turn the history of an object into a revenue-generating asset for the protocol. Every scan and every transfer signature becomes a micro-transactional proof-of-status. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "HardTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-generative-mint-studio-1-x402 Title: Gen-Seed · x402 Theme: Visual Art (visual-art) · generative art minting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless seed-to-string engine where 0.01 USDC triggers a unique generative iteration. Instead of a heavy upfront minting fee, users pay per 'Evolution Sign-off.' Artists deploy logic; collectors pay per high-fidelity render, receiving a Hedera transaction id as proof of unique generation. The payment is the shutter click. Why Hedera: By moving from 'Art as a Service' to 'Payment as the Shutter,' we capture the high-frequency nature of generative exploration. x402 eliminates the friction of gas-estimation for collectors, turning every 0.01 USDC micro-payment into a cryptographically signed creative act. Market: TAM $3.2B — The global digital art and collectibles market, increasingly shifting toward algorithmic production. | SAM $450M — The projected market for generative NFTs and AI-assisted art assets. | SOM $12M — Micro-transaction volume from high-frequency collectors and generative art enthusiasts on Layer 2. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Gen-Seed" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless seed-to-string engine where 0.01 USDC triggers a unique generative iteration. Instead of a heavy upfront minting fee, users pay per 'Evolution Sign-off.' Artists deploy logic; collectors pay per high-fidelity render, receiving a Hedera transaction id as proof of unique generation. The payment is the shutter click. Discipline: Visual Art (generative art minting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'Art as a Service' to 'Payment as the Shutter,' we capture the high-frequency nature of generative exploration. x402 eliminates the friction of gas-estimation for collectors, turning every 0.01 USDC micro-payment into a cryptographically signed creative act. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Gen-Seed" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-exhibit-chain-ledger-2-x402 Title: Exhibit · x402 Theme: Visual Art (visual-art) · gallery exhibition tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Provenance is a living ledger, not a static database. Exhibit pays the artist 0.01 USDC every time a curator, insurer, or buyer pulls a verified exhibition history or updates a physical location tag. Turn the 'paper trail' into a real-time revenue stream for estates and digital creators. Why Hedera: By shifting from a one-time registration fee to a 'pay-per-access/update' model, curators pay for the verified truth, and the data remains liquid and updated. x402 handles the high-frequency micro-logs of art moving through global logistics. Market: TAM $65B — The global fine art market and the emerging RWA (Real World Asset) tokenization economy. son. | SAM $420M — Professional galleries, high-net-worth collectors, and boutique art insurers on Hedera. | SOM $18M — Independent physical galleries and digital-native art platforms requiring real-time provenance verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Exhibit" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Provenance is a living ledger, not a static database. Exhibit pays the artist 0.01 USDC every time a curator, insurer, or buyer pulls a verified exhibition history or updates a physical location tag. Turn the 'paper trail' into a real-time revenue stream for estates and digital creators. Discipline: Visual Art (gallery exhibition tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a one-time registration fee to a 'pay-per-access/update' model, curators pay for the verified truth, and the data remains liquid and updated. x402 handles the high-frequency micro-logs of art moving through global logistics. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Exhibit" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-colorcode-certifier-3-x402 Title: TrueHue · x402 Theme: Visual Art (visual-art) · color authenticity validation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for true-spectrum validation. Pay 0.01 USDC to generate a cryptographically signed 'Color Fingerprint' for any digital or digitized physical artwork. The app hashes hexadecimal sequences against high-resolution pixel data, creating a permanent on-chain record of authenticity that prevents color-shifting theft or AI-generated replicas. Pay-per-certification for artists; pay-per-verification for collectors. Why Hedera: Micropayments turn color validation from a high-barrier legal process into a high-velocity utility. By metering the 'verify' call, the app creates a self-sustaining ledger of creative truth where the cost of verification is negligible for the user but significant for maintaining the integrity of the art market. Market: TAM $2.8B — The global art authentication and provenance market, expanding into automated AI-content verification. | SAM $150M — Modern digital art marketplaces and NFT platforms requiring metadata permanence and color-space consistency. | SOM $12M — Independent digital illustrators and luxury brand archivists protecting seasonal color palettes on-chain. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueHue" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for true-spectrum validation. Pay 0.01 USDC to generate a cryptographically signed 'Color Fingerprint' for any digital or digitized physical artwork. The app hashes hexadecimal sequences against high-resolution pixel data, creating a permanent on-chain record of authenticity that prevents color-shifting theft or AI-generated replicas. Pay-per-certification for artists; pay-per-verification for collectors. Discipline: Visual Art (color authenticity validation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Micropayments turn color validation from a high-barrier legal process into a high-velocity utility. By metering the 'verify' call, the app creates a self-sustaining ledger of creative truth where the cost of verification is negligible for the user but significant for maintaining the integrity of the art market. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueHue" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-auction-trust-chain-4-x402 Title: Hammer · x402 Theme: Visual Art (visual-art) · art auction transparency Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes bidding protocol where every bid is a sealed commitment requiring a 0.01 USDC x402 'Skin-in-the-Game' fee. This eliminates bid shading and ghost bidding by making every price discovery action a micro-settled transaction on Hedera. Pay per bid, pay per provenance check, and pay to reveal the final hammer price. Why Hedera: By turning bids into paid micropayments, we filter for genuine intent and create a sustainable revenue model for auction houses that scales with activity rather than just commissions. Market: TAM $67B — The global traditional art market transitioning to verifiable digital provenance. | SAM $1.2B — Digital-native and RWA art collectors utilizing Base for onchain auctions. | SOM $45M — Onchain fine art auctions and generative art drop platforms requiring anti-bot bidding. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Hammer" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes bidding protocol where every bid is a sealed commitment requiring a 0.01 USDC x402 'Skin-in-the-Game' fee. This eliminates bid shading and ghost bidding by making every price discovery action a micro-settled transaction on Hedera. Pay per bid, pay per provenance check, and pay to reveal the final hammer price. Discipline: Visual Art (art auction transparency). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning bids into paid micropayments, we filter for genuine intent and create a sustainable revenue model for auction houses that scales with activity rather than just commissions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Hammer" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-brushstroke-timestamp-5-x402 Title: STROKE · x402 Theme: Visual Art (visual-art) · creative process timestamping Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Provenance-as-a-Service for digital painters. Instead of bulk-uploading finished pieces, artists call the x402 endpoint to anchor specific strokes or layers to Base in real-time. Collectors pay a 0.01 USDC micro-fee to 'Peep' the live process, while curators use the hashes to verify authentic human hand-eye coordination against AI-generated mimics. Zero-friction creative audit trails. Why Hedera: Shifts the value from a free 'log' to a paid 'witnessing' and 'verification' protocol. It monetizes the act of creation itself, rather than just the final result, via metered HTS transfer signatures. Market: TAM $65B — The global art market, increasingly reliant on immutable digital certificates of authenticity and process-tracing. | SAM $2.4B — The addressable market for digital art software (Procreate/Adobe users) migrating to decentralized provenance tools. | SOM $18M — The niche of high-end digital illustrators and speed-painters requiring cryptographic proof of process to combat AI-impersonation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STROKE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Provenance-as-a-Service for digital painters. Instead of bulk-uploading finished pieces, artists call the x402 endpoint to anchor specific strokes or layers to Base in real-time. Collectors pay a 0.01 USDC micro-fee to 'Peep' the live process, while curators use the hashes to verify authentic human hand-eye coordination against AI-generated mimics. Zero-friction creative audit trails. Discipline: Visual Art (creative process timestamping). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the value from a free 'log' to a paid 'witnessing' and 'verification' protocol. It monetizes the act of creation itself, rather than just the final result, via metered HTS transfer signatures. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STROKE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-licensing-ledger-6-x402 Title: Atelier · x402 Theme: Visual Art (visual-art) · artwork license management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency licensing gateway where digital assets are metered by the millisecond. Instead of static contracts, creators set a micropayment stream (0.01 USDC) for every instance of high-res rendering, commercial display, or AI training usage. Each glance or API call triggers an HTS transfer transfer, returning a Hedera transaction id as a cryptographically verifiable proof-of-usage. Why Hedera: Shifts licensing from a 'buy once' model to a 'metered use' utility. By using x402, the payment becomes the heartbeat of the license enforcement, making it feasible for AI agents to legally ingest art at scale. Market: TAM $108B — The global intellectual property and digital content licensing market. | SAM $4.2B — The growing market for AI training data and programmatic media licensing. | SOM $65M — Independent digital artists and boutique stock agencies moving to per-view monetization on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Atelier" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency licensing gateway where digital assets are metered by the millisecond. Instead of static contracts, creators set a micropayment stream (0.01 USDC) for every instance of high-res rendering, commercial display, or AI training usage. Each glance or API call triggers an HTS transfer transfer, returning a Hedera transaction id as a cryptographically verifiable proof-of-usage. Discipline: Visual Art (artwork license management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts licensing from a 'buy once' model to a 'metered use' utility. By using x402, the payment becomes the heartbeat of the license enforcement, making it feasible for AI agents to legally ingest art at scale. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Atelier" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-interactive-canvas-dao-7-x402 Title: Vandal · x402 Theme: Visual Art (visual-art) · collaborative art governance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-pixel. A hyper-granular collaborative mural where every stroke, color change, or vote to 'lock' a section requires a signature-authorized micropayment. No free riding in the digital commons: influencers and artists pay USDC to overwrite or defend their territory. Settlement happens instantly per interaction, turning the canvas into a real-time sovereign economic battlefield. Why Hedera: Traditional DAOs suffer from voter apathy and gas-heavy governance. By making every edit a metered x402 payment, the 'cost to participate' acts as a sybil-resistance mechanism and direct revenue stream for the vault, ensuring only high-conviction strokes are made. Market: TAM $2.1B — The global generative and collaborative art economy mediated by AI and human agents. | SAM $140M — The programmable digital art market and collaborative 'r/place' style social experiments. | SOM $8M — Competitive social gaming and DAO-governed creative hubs on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vandal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-pixel. A hyper-granular collaborative mural where every stroke, color change, or vote to 'lock' a section requires a signature-authorized micropayment. No free riding in the digital commons: influencers and artists pay USDC to overwrite or defend their territory. Settlement happens instantly per interaction, turning the canvas into a real-time sovereign economic battlefield. Discipline: Visual Art (collaborative art governance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional DAOs suffer from voter apathy and gas-heavy governance. By making every edit a metered x402 payment, the 'cost to participate' acts as a sybil-resistance mechanism and direct revenue stream for the vault, ensuring only high-conviction strokes are made. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vandal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-art-swap-protocol-8-x402 Title: CanvasSwap · x402 Theme: Visual Art (visual-art) · peer-to-peer art exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Curate and trade digital canvases where every view, bid, and swap is metered via micropayments. A high-velocity 'Art Tinder' where artists earn 0.01 USDC for every swipe-right (buy-interest) and collectors pay-per-offer. Settlement occurs instantly, turning low-fidelity discovery into high-signal liquidity. Why Hedera: By replacing high-friction flat fees with 0.01 USDC per-interaction (view high-res, place bid, reveal hidden metadata), the protocol monetizes the discovery process itself, not just the final sale. This creates a sustainable income stream for active artists even when pieces aren't trading. Market: TAM $5.4B — The global peer-to-peer art and collectibles market migrating to programmatic settlement. | SAM $290M — The projected turnover for generative and digital-native art platforms on Layer 2s. | SOM $14M — Active onchain collectors and high-frequency art trading bots on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CanvasSwap" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Curate and trade digital canvases where every view, bid, and swap is metered via micropayments. A high-velocity 'Art Tinder' where artists earn 0.01 USDC for every swipe-right (buy-interest) and collectors pay-per-offer. Settlement occurs instantly, turning low-fidelity discovery into high-signal liquidity. Discipline: Visual Art (peer-to-peer art exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing high-friction flat fees with 0.01 USDC per-interaction (view high-res, place bid, reveal hidden metadata), the protocol monetizes the discovery process itself, not just the final sale. This creates a sustainable income stream for active artists even when pieces aren't trading. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CanvasSwap" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-edition-tracker-9-x402 Title: Provenance · x402 Theme: Visual Art (visual-art) · limited print tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity registry for digital and physical rarities where every provenance check and edition claim is a micro-settlement. Artists issue assets; buyers pay $0.01 to verify authenticity or claim the next serial in a sequence, eliminating the overhead of gas and friction of subscription tools. It turns every 'check' into a direct micro-royalty for the creator. Why Hedera: By moving from a 'database' model to an x402 'metered' model, the act of verification becomes a revenue stream. Collectors pay a negligible fee to ensure their print is authentic, and artists are incentivized to maintain active registries because every interaction generates immediate USDC. Market: TAM $4B — Global limited-edition art and luxury goods market transitioning to digital twins. | SAM $140M — The digital art and high-end collectibles market using onchain verification. | SOM $12M — Independent printmakers and photography collectives requiring verifiable editioning without heavy marketplace fees. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Provenance" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity registry for digital and physical rarities where every provenance check and edition claim is a micro-settlement. Artists issue assets; buyers pay $0.01 to verify authenticity or claim the next serial in a sequence, eliminating the overhead of gas and friction of subscription tools. It turns every 'check' into a direct micro-royalty for the creator. Discipline: Visual Art (limited print tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a 'database' model to an x402 'metered' model, the act of verification becomes a revenue stream. Collectors pay a negligible fee to ensure their print is authentic, and artists are incentivized to maintain active registries because every interaction generates immediate USDC. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Provenance" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-art-commission-chain-10-x402 Title: Proof of Stroke · x402 Theme: Visual Art (visual-art) · commission workflow management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-revision. Eliminate the friction of 'half upfront' deposits. Artists publish gated progressive layers (sketches, linework, flats) that patrons unlock instantly via HTS transfer. Every feedback loop or high-res export is a micro-settled transaction on Hedera testnet. Payment becomes the heartbeat of the creative workflow, protecting the artist's time while giving the patron an 'undo' button at any milestone. Why Hedera: Traditional commission models suffer from trust-fall dynamics. By turning the workflow into a series of x402-gated micro-deliverables, the artist is paid for every minute of labor, and the patron pays only for progress they approve. Market: TAM $8.5B — The global freelance art and commission economy adopting streaming micro-settlements. | SAM $420M — Web3-native artists and digital illustrators utilizing mid-roll payment milestones. | SOM $12M — Freelance character illustrators on Hedera and Farcaster using automated delivery bots. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proof of Stroke" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-revision. Eliminate the friction of 'half upfront' deposits. Artists publish gated progressive layers (sketches, linework, flats) that patrons unlock instantly via HTS transfer. Every feedback loop or high-res export is a micro-settled transaction on Hedera testnet. Payment becomes the heartbeat of the creative workflow, protecting the artist's time while giving the patron an 'undo' button at any milestone. Discipline: Visual Art (commission workflow management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional commission models suffer from trust-fall dynamics. By turning the workflow into a series of x402-gated micro-deliverables, the artist is paid for every minute of labor, and the patron pays only for progress they approve. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proof of Stroke" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-dynamic-frame-nfts-11-x402 Title: BORDERLINE · x402 Theme: Visual Art (visual-art) · programmable artwork framing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A programmable border-engine where every aesthetic shift is a transaction. Collectors or algorithms pay 0.01 USDC to 'Cycle' the visual context of an asset. Use the x402 primitive to meter 'Gaze-Based Evolution' (auto-shifting frames based on how many wall-clocks pass) or 'Oracle-Linked Skins' (weather/price/time-of-day) where each asset state-change is a paid, verifiable event. Pay to re-frame your collection for a specific gallery mood. Why Hedera: By moving from static NFT metadata to a pay-per-mutation model, the frame becomes an active service rather than a one-time purchase. Owners can 'subscribe' their art to dynamic environment changes, creating a continuous revenue stream for the developer/artist for every border refresh. Market: TAM $8.5B — The global programmable digital art market and smart-home display industry. | SAM $420M — Digital art collectors and DAOs seeking 'living' on-chain displays. | SOM $12M — Early adopters on Hedera testnet utilizing HashPack-integrated galleries for automated asset styling. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "BORDERLINE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A programmable border-engine where every aesthetic shift is a transaction. Collectors or algorithms pay 0.01 USDC to 'Cycle' the visual context of an asset. Use the x402 primitive to meter 'Gaze-Based Evolution' (auto-shifting frames based on how many wall-clocks pass) or 'Oracle-Linked Skins' (weather/price/time-of-day) where each asset state-change is a paid, verifiable event. Pay to re-frame your collection for a specific gallery mood. Discipline: Visual Art (programmable artwork framing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from static NFT metadata to a pay-per-mutation model, the frame becomes an active service rather than a one-time purchase. Owners can 'subscribe' their art to dynamic environment changes, creating a continuous revenue stream for the developer/artist for every border refresh. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "BORDERLINE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-gallery-access-token-12-x402 Title: Vernissage · x402 Theme: Visual Art (visual-art) · event admission tokens Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A friction-less digital turnstile for fine art. Gallery visitors tap to sign a 0.01 USDC transfer via the embedded wallet to unlock high-res exhibition maps, private audio guides, or physical entry verification. The payment is the credential, eliminating bulky ticketing apps for instant, micro-transactional entry. Why Hedera: By moving from 'NFT tickets' to a 'micro-payment per admission' model, galleries can monetize casual foot traffic and digital previews without subscription friction. Every interaction—from viewing a piece's provenance to entering a restricted wing—becomes a sub-cent revenue event. Market: TAM $28B — The global event ticketing and museum admission market transitioning to automated entry. | SAM $1.8B — Global independent art galleries and temporary pop-up exhibitions adopting digital signage. | SOM $45M — Niche digital art festivals and boutique gallery openings on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vernissage" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A friction-less digital turnstile for fine art. Gallery visitors tap to sign a 0.01 USDC transfer via the embedded wallet to unlock high-res exhibition maps, private audio guides, or physical entry verification. The payment is the credential, eliminating bulky ticketing apps for instant, micro-transactional entry. Discipline: Visual Art (event admission tokens). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'NFT tickets' to a 'micro-payment per admission' model, galleries can monetize casual foot traffic and digital previews without subscription friction. Every interaction—from viewing a piece's provenance to entering a restricted wing—becomes a sub-cent revenue event. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vernissage" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-art-critique-chain-13-x402 Title: Critiq · x402 Theme: Visual Art (visual-art) · peer review logging Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn the 'starving artist' trope into a high-fidelity feedback loop. Critiq operates as a metered critique engine where every peer review, redline, or technical teardown is a paid micro-transaction. By putting a $0.01 price tag on the 'Approve' or 'Comment' button, you filter for skin-in-the-game insights while ensuring the reviewer is compensated for their eye. Every critique is a signed HTS transfer receipt, creating a verifiable provenance of an artist's growth. Why Hedera: Traditional peer review is often low-effort or sycophantic. By introducing x402 micropayments, we monetize the 'expert eye' and turn feedback into a professional service rather than a favor. This creates a data-rich ledger of professional validation that agents can use to verify talent. Market: TAM $1.4B — The global art education and professional development market, shifting toward decentralized, peer-to-peer accreditation. | SAM $120M — Focused on the 40M+ active artists on digital portfolio platforms (ArtStation, Behance) seeking professional-grade feedback. | SOM $8M — Initial target: Emerging concept artists and students in 3D/VFX bootcamps requiring high-frequency critique logs for portfolio certification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Critiq" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn the 'starving artist' trope into a high-fidelity feedback loop. Critiq operates as a metered critique engine where every peer review, redline, or technical teardown is a paid micro-transaction. By putting a $0.01 price tag on the 'Approve' or 'Comment' button, you filter for skin-in-the-game insights while ensuring the reviewer is compensated for their eye. Every critique is a signed HTS transfer receipt, creating a verifiable provenance of an artist's growth. Discipline: Visual Art (peer review logging). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional peer review is often low-effort or sycophantic. By introducing x402 micropayments, we monetize the 'expert eye' and turn feedback into a professional service rather than a favor. This creates a data-rich ledger of professional validation that agents can use to verify talent. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Critiq" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-signature-stamp-14-x402 Title: TrueInk · x402 Theme: Visual Art (visual-art) · artist signature authentication Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to instantly certify an artist's signature or authenticate a work's provenance. Each verification triggers an on-chain attestation, creating a high-fidelity audit trail for collectors and galleries without monthly subscription bloat. Artists monetize their legacy per-view; collectors secure their assets per-click. Why Hedera: By moving from a subscription model to a pay-per-verification (x402) model, provenance becomes a billable event rather than an overhead cost. This aligns incentives for high-frequency secondary markets and automated gallery inventory checks. Market: TAM $4.2B — The global art forgery and authentication market. | SAM $110M — The digital art market and high-end print authentication sector. | SOM $4.5M — Independent digital artists and boutique physical galleries on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TrueInk" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to instantly certify an artist's signature or authenticate a work's provenance. Each verification triggers an on-chain attestation, creating a high-fidelity audit trail for collectors and galleries without monthly subscription bloat. Artists monetize their legacy per-view; collectors secure their assets per-click. Discipline: Visual Art (artist signature authentication). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a subscription model to a pay-per-verification (x402) model, provenance becomes a billable event rather than an overhead cost. This aligns incentives for high-frequency secondary markets and automated gallery inventory checks. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TrueInk" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-interactive-auction-game-15-x402 Title: VELOCITY VOTE · x402 Theme: Visual Art (visual-art) · gamified art auctions Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Bid-to-Stroke: the auction is the creation. 0.01 USDC unlocks a 10-second window to manipulate a collaborative 3D generative canvas. Every micro-payment moves the price floor and extends the timer. The final frame is minted as an NFT to the highest spender, with all 'bid-calls' distributed to the artist in real-time. Payment isn't just a bid; it's the brushstroke. Why Hedera: Moving from a static bidding war to a pay-per-interaction model turns the auction into a high-velocity revenue stream. HTS transfer allows for frictionless ‘micro-bidding’ without gas-friction or pop-up fatigue, turning the UI into an addictive playground for collectors. Market: TAM $2.1B — The global intersection of online art auctions, social gaming, and micro-transaction economies. | SAM $450M — The digital art and gamified collectibles market on L2 networks. | SOM $12M — The niche for collaborative, generative art droplets and 'pay-to-play' digital exhibitions on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VELOCITY VOTE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Bid-to-Stroke: the auction is the creation. 0.01 USDC unlocks a 10-second window to manipulate a collaborative 3D generative canvas. Every micro-payment moves the price floor and extends the timer. The final frame is minted as an NFT to the highest spender, with all 'bid-calls' distributed to the artist in real-time. Payment isn't just a bid; it's the brushstroke. Discipline: Visual Art (gamified art auctions). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a static bidding war to a pay-per-interaction model turns the auction into a high-velocity revenue stream. HTS transfer allows for frictionless ‘micro-bidding’ without gas-friction or pop-up fatigue, turning the UI into an addictive playground for collectors. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VELOCITY VOTE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-material-usage-ledger-16-x402 Title: TruePigment · x402 Theme: Visual Art (visual-art) · supply chain transparency Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Prove the provenance of every pigment. Pay 0.01 USDC to cryptographically verify a material's ethical source ledger or sign-off on a new batch of raw supply. This turns transparency from a static claim into a verifiable micropayment event, allowing collectors to 'audit' an artwork's physical composition for cents. Why Hedera: By turning ledger entries and verification requests into micropayments, we eliminate spam and reward auditors. 402-authorizations create a permanent, paid heartbeat for the supply chain, making ethical sourcing a profitable data-service. Market: TAM $2.4B — Global art materials market transitioning to transparent, digital-twin supply chains. | SAM $450M — Fine art and sustainable luxury market segments demanding verifiable ESG data. | SOM $12M — Fair-trade mineral suppliers and archival-grade pigment producers onboarding to Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "TruePigment" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Prove the provenance of every pigment. Pay 0.01 USDC to cryptographically verify a material's ethical source ledger or sign-off on a new batch of raw supply. This turns transparency from a static claim into a verifiable micropayment event, allowing collectors to 'audit' an artwork's physical composition for cents. Discipline: Visual Art (supply chain transparency). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning ledger entries and verification requests into micropayments, we eliminate spam and reward auditors. 402-authorizations create a permanent, paid heartbeat for the supply chain, making ethical sourcing a profitable data-service. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "TruePigment" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-visual-royalties-manager-17-x402 Title: KINETIC · x402 Theme: Visual Art (visual-art) · artist royalty automation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Provenance-as-a-Service. Every time a digital canvas is displayed, curated, or transferred, the viewer or new owner triggers a 0.01 USDC HTS transfer signature. This isn't a retrospective royalty check; it's a real-time heartbeat payment that streams to the artist's wallet instantly, turning every 'view' or 'trade' into a high-velocity revenue event settled on Hedera. Why Hedera: Traditional royalties rely on marketplace compliance. By embedding x402 at the asset resolution layer, the artwork itself becomes the meter. Payment is the unlock for the high-res file or the metadata update, ensuring the artist is paid at the point of interaction rather than waiting for accounting cycles. Market: TAM $62B — The global art market, transitioning toward digital provenance and automated secondary market enforcement. | SAM $850M — The addressable market for digital art secondary sales and high-frequency curation protocols. | SOM $45M — Initial capture of Base-native NFT platforms and generative art collections utilizing micro-licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KINETIC" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Provenance-as-a-Service. Every time a digital canvas is displayed, curated, or transferred, the viewer or new owner triggers a 0.01 USDC HTS transfer signature. This isn't a retrospective royalty check; it's a real-time heartbeat payment that streams to the artist's wallet instantly, turning every 'view' or 'trade' into a high-velocity revenue event settled on Hedera. Discipline: Visual Art (artist royalty automation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional royalties rely on marketplace compliance. By embedding x402 at the asset resolution layer, the artwork itself becomes the meter. Payment is the unlock for the high-res file or the metadata update, ensuring the artist is paid at the point of interaction rather than waiting for accounting cycles. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KINETIC" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-moodboard-dao-18-x402 Title: VisionBank · x402 Theme: Visual Art (visual-art) · collective inspiration curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-fragmented creative brain. Pay 0.01 USDC to pin a reference or 'vision-lock' a collective board. Every addition is a micro-investment in the project's aesthetic direction, with fees flowing to the top contributors when the board is finalized or licensed. Metascent and color palettes behind a micro-paywall. Why Hedera: Shifts governance from heavy voting to skin-in-the-game curation. High-velocity visual feedback becomes a revenue stream for curators. Market: TAM $3.2B — Global collaborative design and asset management. | SAM $450M — The creative direction and digital moodboarding market. | SOM $12M — Decentralized studio teams and AI-image prompt engineers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VisionBank" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-fragmented creative brain. Pay 0.01 USDC to pin a reference or 'vision-lock' a collective board. Every addition is a micro-investment in the project's aesthetic direction, with fees flowing to the top contributors when the board is finalized or licensed. Metascent and color palettes behind a micro-paywall. Discipline: Visual Art (collective inspiration curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts governance from heavy voting to skin-in-the-game curation. High-velocity visual feedback becomes a revenue stream for curators. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VisionBank" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-sketch-snapshot-chain-19-x402 Title: InkTrace · x402 Theme: Visual Art (visual-art) · digital sketch archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Chronicle the evolution of your creative process. Pay 0.01 USDC to hash, timestamp, and permanently archive a high-res snapshot of your digital canvas to Base. No subscriptions, just a micro-fee per save to build an immutable, tamper-proof record of every stroke. Why Hedera: By shifting from a free archive to a pay-per-snapshot model, the app treats 'creative progress' as a series of billable milestones. Using x402, artists can meter their archival habits, paying only when a sketch reaches a point worth preserving, turning a personal journal into a professional-grade audit trail. Market: TAM $2.1B — The global digital art and collectibles market, increasingly moving toward onchain provenance. | SAM $240M — Addressable segment of digital concept artists and technical illustrators requiring secure version control. | SOM $12M — Initial capture of the professional concept art and 'process-video' creator community on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Chronicle the evolution of your creative process. Pay 0.01 USDC to hash, timestamp, and permanently archive a high-res snapshot of your digital canvas to Base. No subscriptions, just a micro-fee per save to build an immutable, tamper-proof record of every stroke. Discipline: Visual Art (digital sketch archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a free archive to a pay-per-snapshot model, the app treats 'creative progress' as a series of billable milestones. Using x402, artists can meter their archival habits, paying only when a sketch reaches a point worth preserving, turning a personal journal into a professional-grade audit trail. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-canvas-rental-token-20-x402 Title: Atelier · x402 Theme: Visual Art (visual-art) · art space access Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-minute studio access. Artists unlock smart-locks, power outlets, or high-end lighting rigs via x402-metered sessions. No monthly rent or deposits; the studio operates as a physical API, billing the artist's Magic Link email sign-in in real-time as they occupy the space. Pay only for the duration of the creative flow. Why Hedera: Traditional rentals have high friction (contracts, deposits). x402 turns physical gallery/studio space into a metered utility, allowing emerging artists to 'rent' professional environments for the exact price of a single session. Market: TAM $8.2B — The global flexible office and studio rental market transitioning to automated, trustless access. | SAM $450M — The shared workspace and 'co-warehousing' market for niche creative disciplines. | SOM $12M — Web3-integrated art hubs and tech-forward residency programs in tier-1 cities. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Atelier" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-minute studio access. Artists unlock smart-locks, power outlets, or high-end lighting rigs via x402-metered sessions. No monthly rent or deposits; the studio operates as a physical API, billing the artist's Magic Link email sign-in in real-time as they occupy the space. Pay only for the duration of the creative flow. Discipline: Visual Art (art space access). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional rentals have high friction (contracts, deposits). x402 turns physical gallery/studio space into a metered utility, allowing emerging artists to 'rent' professional environments for the exact price of a single session. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Atelier" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-nft-curation-index-21-x402 Title: Gallery Tape · x402 Theme: Visual Art (visual-art) · curated NFT collections Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view gallery protocol where every 'Look' or 'Full Resolution Unlock' triggers a 0.01 USDC streaming payment directly to the curator. Collectors pay-per-vote to influence the index, removing algorithmic bias in favor of direct financial skin-in-the-game. High-signal curation becomes a metered API for digital décor providers. Why Hedera: Traditional NFT galleries suffer from 'browse for free' fatigue and low creator monetization. By making every high-fidelity interaction a micropayment, curation is transformed from an amateur hobby into a professionalized, metered data service. Market: TAM $4.2B — The global digital art market, increasingly reliant on decentralized curation and algorithmic discovery. | SAM $850M — The projected market for digital art display services and curated metadata feeds in the Web3 ecosystem. | SOM $12M — Transaction volume from premium curated art 'Discovery APIs' and pay-to-vote mechanisms on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Gallery Tape" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view gallery protocol where every 'Look' or 'Full Resolution Unlock' triggers a 0.01 USDC streaming payment directly to the curator. Collectors pay-per-vote to influence the index, removing algorithmic bias in favor of direct financial skin-in-the-game. High-signal curation becomes a metered API for digital décor providers. Discipline: Visual Art (curated NFT collections). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT galleries suffer from 'browse for free' fatigue and low creator monetization. By making every high-fidelity interaction a micropayment, curation is transformed from an amateur hobby into a professionalized, metered data service. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Gallery Tape" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-brushstroke-dao-vault-22-x402 Title: InkStream · x402 Theme: Visual Art (visual-art) · collective art funding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-pixel collective patron model. Users authorize 0.01 USDC to unlock a single stroke of a high-resolution, community-funded canvas. Every payment is a direct contribution to a smart-contract vault that releases funds to the artist via HTS transfer signed authorizations. Watch the masterpiece emerge as each micropayment triggers a Base settlement, turning art appreciation into a real-time, metered funding mechanism. Why Hedera: By shifting from a bulk deposit DAO model to a granular 'fee-per-stroke' mechanism, the friction of patronage is removed. x402 allows for high-velocity funding where the act of viewing or contributing is the financial primitive, creating a provable stream of revenue for artists. Market: TAM $2.8B — The global visual art crowdfunding and digital patronage economy. | SAM $450M — The addressable market for digital collectibles and on-chain generative art funding. | SOM $12M — Early adopters of Base-native art experiments and high-frequency micro-patrons. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-pixel collective patron model. Users authorize 0.01 USDC to unlock a single stroke of a high-resolution, community-funded canvas. Every payment is a direct contribution to a smart-contract vault that releases funds to the artist via HTS transfer signed authorizations. Watch the masterpiece emerge as each micropayment triggers a Base settlement, turning art appreciation into a real-time, metered funding mechanism. Discipline: Visual Art (collective art funding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a bulk deposit DAO model to a granular 'fee-per-stroke' mechanism, the friction of patronage is removed. x402 allows for high-velocity funding where the act of viewing or contributing is the financial primitive, creating a provable stream of revenue for artists. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-art-swap-escrow-23-x402 Title: SHIPPED · x402 Theme: Visual Art (visual-art) · secure art trades Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A trustless verification layer for high-stakes art logistics. Collectors pay 0.01 USDC to sign/verify a cryptographic 'handshake' at every point of physical custody transfer. No more disputes over when damage occurred or who signed for the crate—each handover is a micro-settled on-chain attestation. Why Hedera: By turning escrow confirmation into a series of pay-per-signature events, we solve the 'last-mile' trust problem in physical art shipping without requiring expensive legal retainers. Market: TAM $67B — The global annual art market transaction volume requiring provenance and secure transfer. | SAM $850M — The fine art logistics, insurance, and professional appraisal market. | SOM $12M — High-velocity digital-physical twin sales and independent gallery shipping audits. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SHIPPED" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A trustless verification layer for high-stakes art logistics. Collectors pay 0.01 USDC to sign/verify a cryptographic 'handshake' at every point of physical custody transfer. No more disputes over when damage occurred or who signed for the crate—each handover is a micro-settled on-chain attestation. Discipline: Visual Art (secure art trades). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning escrow confirmation into a series of pay-per-signature events, we solve the 'last-mile' trust problem in physical art shipping without requiring expensive legal retainers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SHIPPED" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-visual-art-rewards-24-x402 Title: CanvasGrit · x402 Theme: Visual Art (visual-art) · artist incentivization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-licensing layer for visual assets. Instead of bulk subscriptions or manual invoicing, every 'Save As', 'High-Res Unlock', or 'Commercial Use' action triggers a 0.01 USDC payment directly to the artist's wallet. The API returns a Hedera transaction id as a cryptographically signed receipt of usage rights. Pay for the pixel, not the platform. Why Hedera: Shifts the value prop from 'milestones' (which are infrequent and lagging) to 'consumption' (which is real-time and granular). x402 allows artists to meter their work—charging per-view or per-download—removing the friction of traditional payment gateways for global fans. Market: TAM $14.8B — The global digital art and stock photography market transitioning to programmable, automated licensing. | SAM $1.2B — The creator economy segment moving toward direct-to-fan monetization and micro-patronage. | SOM $85M — Digital illustrators and concept artists currently reliant on ad-share or tip-jar models on platforms like ArtStation or X. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CanvasGrit" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-licensing layer for visual assets. Instead of bulk subscriptions or manual invoicing, every 'Save As', 'High-Res Unlock', or 'Commercial Use' action triggers a 0.01 USDC payment directly to the artist's wallet. The API returns a Hedera transaction id as a cryptographically signed receipt of usage rights. Pay for the pixel, not the platform. Discipline: Visual Art (artist incentivization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the value prop from 'milestones' (which are infrequent and lagging) to 'consumption' (which is real-time and granular). x402 allows artists to meter their work—charging per-view or per-download—removing the friction of traditional payment gateways for global fans. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CanvasGrit" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-permacolor-archive-0-x402 Title: Chromatix · x402 Theme: Visual Art (visual-art) · color palette curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-pull color palettes. Every time a designer or automated styling agent fetches your curated .json palette from IPFS via the registry, you earn USDC. Turn aesthetic taste into a metered API asset. Permanent color theory, monetized per render. Why Hedera: Shifts from 'static storage' to 'active distribution.' In a world of generative UI, agents need high-quality, human-curated color schemas; x402 allows for granular charging for every time a style is applied to a third-party project. Market: TAM $5.8B — The global design tools and digital asset licensing economy. | SAM $420M — The creative professional subscription and digital asset market for UI/UX designers and brand strategists. | SOM $12M — Visual designers on Hedera utilizing automated styling workflows and generative art pipelines. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chromatix" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-pull color palettes. Every time a designer or automated styling agent fetches your curated .json palette from IPFS via the registry, you earn USDC. Turn aesthetic taste into a metered API asset. Permanent color theory, monetized per render. Discipline: Visual Art (color palette curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from 'static storage' to 'active distribution.' In a world of generative UI, agents need high-quality, human-curated color schemas; x402 allows for granular charging for every time a style is applied to a third-party project. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chromatix" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-generative-art-vault-1-x402 Title: GENESIS RECURSE · x402 Theme: Visual Art (visual-art) · algorithmic artwork storage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Archive one generative seed + dependency bundle to permanent decentralized storage. Every time a visitor re-renders your script to view the live art, they pay a micro-settlement to your wallet. Stop hosting fragile canvases; start metering creative execution. x402 handles the HTS transfer signature to unlock the script view in real-time. Why Hedera: Shifts the value from 'static storage' to 'paid execution.' Instead of a one-time fee, the x402 model turns the artwork into a high-fidelity utility: payment triggers the render. Market: TAM $2.4B — The global digital asset preservation and provenance market for the agentic web. | SAM $125M — The emerging 'Paid-to-View' digital art market and generative NFT metadata services. | SOM $850K — Initial adoption by algorithmic artists on Hedera looking for perpetual, self-monetizing script hosting. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GENESIS RECURSE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Archive one generative seed + dependency bundle to permanent decentralized storage. Every time a visitor re-renders your script to view the live art, they pay a micro-settlement to your wallet. Stop hosting fragile canvases; start metering creative execution. x402 handles the HTS transfer signature to unlock the script view in real-time. Discipline: Visual Art (algorithmic artwork storage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the value from 'static storage' to 'paid execution.' Instead of a one-time fee, the x402 model turns the artwork into a high-fidelity utility: payment triggers the render. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GENESIS RECURSE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-gallery-manifest-hub-2-x402 Title: Manifest Hub · x402 Theme: Visual Art (visual-art) · exhibition cataloging Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A 'Proof of Exhibition' protocol where galleries and curators mint immutable, permanent digital catalogs. Every record committed to the ledger requires a 0.01 USDC x402 trigger, ensuring the archive is spam-resistant and high-signal. Collectors and researchers pay a micropayment to unlock high-res provenance data, with revenue flowing directly to the artists and curatorial staff. Why Hedera: By making cataloging a pay-per-entry and pay-per-view system, we turn ephemeral art history into a sustainable on-chain asset class. The x402 primitive replaces expensive physical printing with a low-friction, high-integrity digital alternative. Market: TAM $65B — The global traditional art market currently shifting toward digital documentation and blockchain-based provenance. | SAM $4.2B — The growing market for digital provenance, NFT metadata management, and online art sales platforms. | SOM $15M — Targeting high-end boutique galleries and independent curators on Hedera who require immutable exhibition archives. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Manifest Hub" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A 'Proof of Exhibition' protocol where galleries and curators mint immutable, permanent digital catalogs. Every record committed to the ledger requires a 0.01 USDC x402 trigger, ensuring the archive is spam-resistant and high-signal. Collectors and researchers pay a micropayment to unlock high-res provenance data, with revenue flowing directly to the artists and curatorial staff. Discipline: Visual Art (exhibition cataloging). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making cataloging a pay-per-entry and pay-per-view system, we turn ephemeral art history into a sustainable on-chain asset class. The x402 primitive replaces expensive physical printing with a low-friction, high-integrity digital alternative. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Manifest Hub" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-illustrator-portfolio-lock-3-x402 Title: Vault · x402 Theme: Visual Art (visual-art) · portfolio archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-view high-fidelity gallery for illustrators. Instead of public IPFS links that allow headless scraping, 'Vault' requires a 0.01 USDC x402 signature to decrypt and serve high-resolution assets. Creators earn instantly every time a scout, client, or AI agent views their work, turning a static portfolio into a metered distribution engine. Why Hedera: Moving from 'archiving' to 'metered access' solves the monetization gap for illustrators. By using x402, the artist controls the 'read' function of their IPFS data, ensuring that every professional interaction is settled on-chain. Market: TAM $4.2B — The global freelance illustrator and digital asset economy. | SAM $850M — The digital art licensing and portfolio hosting market. | SOM $12M — Web3-native illustrators and concept artists seeking IP protection on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vault" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-view high-fidelity gallery for illustrators. Instead of public IPFS links that allow headless scraping, 'Vault' requires a 0.01 USDC x402 signature to decrypt and serve high-resolution assets. Creators earn instantly every time a scout, client, or AI agent views their work, turning a static portfolio into a metered distribution engine. Discipline: Visual Art (portfolio archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'archiving' to 'metered access' solves the monetization gap for illustrators. By using x402, the artist controls the 'read' function of their IPFS data, ensuring that every professional interaction is settled on-chain. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vault" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-digital-canvas-ledger-4-x402 Title: Brushmark · x402 Theme: Visual Art (visual-art) · painting provenance tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Every stroke's lineage, verified. $0.01 per provenance update to cryptographically anchor painting metadata, high-res scans, and transfer history. No subscriptions—just pay-per-pin to mint a bulletproof chain of custody that travels with the physical piece. At time of sale, buyers pay a micro-fee to verify the ledger, ensuring zero-knowledge authenticity. Why Hedera: By turning provenance into a pay-per-event utility, we eliminate the friction of high-cost art registry services. x402 allows for granular logging (canvas prep, mid-painting, completion) that builds a dense value-graph for the artwork. Market: TAM $3.2B — The global art authentication and provenance tracking market as it transitions to distributed trust. | SAM $180M — The addressable market for digital certificates in the high-end contemporary and emerging art sectors. | SOM $12M — Serving the 'Phygital' and independent artist community using Base for low-cost on-chain verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Brushmark" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Every stroke's lineage, verified. $0.01 per provenance update to cryptographically anchor painting metadata, high-res scans, and transfer history. No subscriptions—just pay-per-pin to mint a bulletproof chain of custody that travels with the physical piece. At time of sale, buyers pay a micro-fee to verify the ledger, ensuring zero-knowledge authenticity. Discipline: Visual Art (painting provenance tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning provenance into a pay-per-event utility, we eliminate the friction of high-cost art registry services. x402 allows for granular logging (canvas prep, mid-painting, completion) that builds a dense value-graph for the artwork. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Brushmark" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-nft-manifest-generator-5-x402 Title: Manifesto · x402 Theme: Visual Art (visual-art) · digital asset packaging Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity packaging engine that pins assets to IPFS and generates verifiable metadata manifests. No subscriptions or bulk gas fees; users pay 0.01 USDC per artifact finalized. Ideal for high-volume digital artists and automated minting pipelines requiring deterministic provenance without overhead. Why Hedera: By turning metadata sealing into a per-use micro-transaction, we eliminate the friction of 'pre-paying' for storage credits. The payment act itself serves as the cryptographic trigger for the pinning event, establishing a clear link between settlement and asset permanence. Market: TAM $2.8B — The global digital asset management and blockchain provenance market for creative IP. | SAM $450M — The overhead market for NFT tooling, individual creator platforms, and 'minting-as-a-service' API providers. | SOM $12M — High-velocity digital artists and generative collection engines on Hedera seeking low-cost, automated IPFS deployment. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Manifesto" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity packaging engine that pins assets to IPFS and generates verifiable metadata manifests. No subscriptions or bulk gas fees; users pay 0.01 USDC per artifact finalized. Ideal for high-volume digital artists and automated minting pipelines requiring deterministic provenance without overhead. Discipline: Visual Art (digital asset packaging). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning metadata sealing into a per-use micro-transaction, we eliminate the friction of 'pre-paying' for storage credits. The payment act itself serves as the cryptographic trigger for the pinning event, establishing a clear link between settlement and asset permanence. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Manifesto" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-interactive-art-pinning-6-x402 Title: Static Pulse · x402 Theme: Visual Art (visual-art) · multimedia art preservation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Preserve interactive digital experiences on-chain with x402-metered redundancy. Pay 0.01 USDC per state-save to snapshot asset dependencies (JSON, GLB, JS) and broadcast their IPFS CIDs to Base. Every ‘pin’ is a micro-settlement that secures the long-term resolution of the work, ensuring media permanence through paid validator incentives. Why Hedera: Shifts preservation from a 'free' hobbyist activity to a sustainable, pay-per-use utility. x402 enables granular billing for each metadata update or file-hash broadcast, making archival-as-a-service viable for high-volume digital collections. Market: TAM $1.2B — Total market for decentralized storage gateways and blockchain preservation services. | SAM $140M — Addressable by digital art registries and institutional archives moving to Base for provenance. | SOM $12M — Target focus on generative art collectors and interactive NFT platforms requiring high-frequency metadata pinning. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Static Pulse" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Preserve interactive digital experiences on-chain with x402-metered redundancy. Pay 0.01 USDC per state-save to snapshot asset dependencies (JSON, GLB, JS) and broadcast their IPFS CIDs to Base. Every ‘pin’ is a micro-settlement that secures the long-term resolution of the work, ensuring media permanence through paid validator incentives. Discipline: Visual Art (multimedia art preservation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts preservation from a 'free' hobbyist activity to a sustainable, pay-per-use utility. x402 enables granular billing for each metadata update or file-hash broadcast, making archival-as-a-service viable for high-volume digital collections. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Static Pulse" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-exhibit-provenance-chain-7-x402 Title: VERIFIED · x402 Theme: Visual Art (visual-art) · art exposition verification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Provenance as a utility. $0.01 USDC to cryptographically seal an artwork's exhibition history or verify a gallery's claims. Every 'Scan to Verify' at a global fair triggers a micro-settlement directly to the curator, replacing bulky paper certificates with real-time, paid-access truth. Why Hedera: By turning verification into a micropayment event, you monetize the trust layer of the art market. It prevents 'fakes' by making verification instant and frictionless, while ensuring the registrar or gallery is paid for maintaining the data integrity. Market: TAM $1.8B — The global art market's annual expenditure on due diligence and provenance services. | SAM $90M — The digital art authentication and luxury logistics verification market. | SOM $4.5M — High-end art fairs (Art Basel, Frieze) adopting micro-fee digital cataloging. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VERIFIED" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Provenance as a utility. $0.01 USDC to cryptographically seal an artwork's exhibition history or verify a gallery's claims. Every 'Scan to Verify' at a global fair triggers a micro-settlement directly to the curator, replacing bulky paper certificates with real-time, paid-access truth. Discipline: Visual Art (art exposition verification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning verification into a micropayment event, you monetize the trust layer of the art market. It prevents 'fakes' by making verification instant and frictionless, while ensuring the registrar or gallery is paid for maintaining the data integrity. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VERIFIED" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-painter-s-time-capsule-8-x402 Title: CHRONOCANVAS · x402 Theme: Visual Art (visual-art) · artistic process documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographic provenance engine for fine art. $0.01 USDC per 'Save State' (HTS transfer) to commit a canvas snapshot and process metadata to IPFS/Base. Collectors authenticate via the embedded wallet to pay a micro-fee and unlock the 'Process Stream'—viewing the invisible layers and creative struggle behind the finished piece. Payment at every stroke ensures the provenance is as immutable as the paint. Why Hedera: By turning the archival process into a pay-per-commit model, the artist builds a high-fidelity audit trail of their labor. The x402 primitive transforms the 'time capsule' from a static folder into a metered evidentiary stream that collectors pay to participate in, turning process documentation into a direct revenue source. Market: TAM $67B — The global art market, increasingly reliant on verifiable digital pedigree and decentralized storage. | SAM $880M — Estimated annual volume of the digitally-tracked provenance and art authentication market. | SOM $22M — Capturing 2.5% of digital-native independent artists using Base for transparent on-chain creative workflows. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CHRONOCANVAS" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographic provenance engine for fine art. $0.01 USDC per 'Save State' (HTS transfer) to commit a canvas snapshot and process metadata to IPFS/Base. Collectors authenticate via the embedded wallet to pay a micro-fee and unlock the 'Process Stream'—viewing the invisible layers and creative struggle behind the finished piece. Payment at every stroke ensures the provenance is as immutable as the paint. Discipline: Visual Art (artistic process documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the archival process into a pay-per-commit model, the artist builds a high-fidelity audit trail of their labor. The x402 primitive transforms the 'time capsule' from a static folder into a metered evidentiary stream that collectors pay to participate in, turning process documentation into a direct revenue source. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CHRONOCANVAS" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-visual-art-supply-chain-9-x402 Title: KROMA · x402 Theme: Visual Art (visual-art) · art materials tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-lookup to verify the ethical sourcing and chemical provenance of high-end pigments, canvases, and binders. Collectors and restorers pay a micro-fee to pull immutable certificates from IPFS via Base, ensuring the material integrity of a physical work without a subscription entry barrier. Why Hedera: High-end art collectors and secondary markets demand transparency for material longevity (archival quality). x402 enables a 'pay-per-scan' model for QR-coded artwork tags, turning provenance into a micro-revenue stream for material certifiers. Market: TAM $65B Global Art Market requiring authentication and material transparency. | SAM $1.8B worldwide fine art restoration and valuation industry. | SOM $25M focused on digital provenance for emerging 'Phygital' art and transparent supply chain auditing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "KROMA" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-lookup to verify the ethical sourcing and chemical provenance of high-end pigments, canvases, and binders. Collectors and restorers pay a micro-fee to pull immutable certificates from IPFS via Base, ensuring the material integrity of a physical work without a subscription entry barrier. Discipline: Visual Art (art materials tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: High-end art collectors and secondary markets demand transparency for material longevity (archival quality). x402 enables a 'pay-per-scan' model for QR-coded artwork tags, turning provenance into a micro-revenue stream for material certifiers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "KROMA" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-auto-manifest-builder-10-x402 Title: Manifest Hub · x402 Theme: Visual Art (visual-art) · metadata automation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Precision-engineered provenance. Artists pay 0.01 USDC to trigger the automated compilation of media assets, attributes, and social links into a standardized IPFS manifest. The fee covers high-availability pinning and immediate on-chain verification, ensuring every 'Save' is a permanent, portable record. No subscriptions, just a micro-settlement for every piece of digital heritage created. Why Hedera: Manual metadata management is the highest friction point in NFT production. By turning the manifest generation into a pay-per-use primitive, we eliminate 'junk' pinning while providing a high-value utility that AI agents can utilize for autonomous gallery curation. Market: TAM $3.2B — The total verifiable metadata economy, encompassing RWA tokenization, digital twins, and autonomous IP registries. | SAM $480M — The addressable market of digital artists, photographers, and 3D modelers moving toward decentralized archiving. | SOM $12M — The immediate volume generated by high-frequency minting engines and artist toolkits integrating 'Manifest-as-a-Service' via x402 calls. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Manifest Hub" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Precision-engineered provenance. Artists pay 0.01 USDC to trigger the automated compilation of media assets, attributes, and social links into a standardized IPFS manifest. The fee covers high-availability pinning and immediate on-chain verification, ensuring every 'Save' is a permanent, portable record. No subscriptions, just a micro-settlement for every piece of digital heritage created. Discipline: Visual Art (metadata automation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Manual metadata management is the highest friction point in NFT production. By turning the manifest generation into a pay-per-use primitive, we eliminate 'junk' pinning while providing a high-value utility that AI agents can utilize for autonomous gallery curation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Manifest Hub" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-collaborative-art-archive-11-x402 Title: PROOFSTACK · x402 Theme: Visual Art (visual-art) · group project documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A peer-to-peer ledger for creative consensus. Pay $0.10 to commit a new layer, revision, or documentation log to the permanent collaborative stack. Only paid entries are pinned to the collective IPFS manifest, ensuring every addition to the group record is intentional and stake-weighted. Settlement happens instantly via Base, turning the art archive into a funded, immutable provenance engine. Why Hedera: Documentation in group projects suffers from 'noise'—too many versions and low-quality uploads. By attaching a micro-cost to each 'commit' or 'archive entry,' the team filters for quality and ensures the IPFS pinning costs are pre-funded by the participants themselves. payment serves as a spam filter for version control. Market: TAM $4.2B — The global Collaborative Software and Digital Asset Management market. | SAM $850M — The market for DAOs and decentralized creative collectives requiring transparent, verifiable contribution logs. | SOM $12M — Professional design studios and art universities adopting Base for high-integrity project handoffs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PROOFSTACK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A peer-to-peer ledger for creative consensus. Pay $0.10 to commit a new layer, revision, or documentation log to the permanent collaborative stack. Only paid entries are pinned to the collective IPFS manifest, ensuring every addition to the group record is intentional and stake-weighted. Settlement happens instantly via Base, turning the art archive into a funded, immutable provenance engine. Discipline: Visual Art (group project documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Documentation in group projects suffers from 'noise'—too many versions and low-quality uploads. By attaching a micro-cost to each 'commit' or 'archive entry,' the team filters for quality and ensures the IPFS pinning costs are pre-funded by the participants themselves. payment serves as a spam filter for version control. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PROOFSTACK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-style-transfer-keeper-12-x402 Title: Style Vault · x402 Theme: Visual Art (visual-art) · AI style preservation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Secure your unique AI latent-space fingerprints on-chain. Style Vault allows artists to lock proprietary LoRA weights and style parameters behind a pay-per-inference gate. Fans and fellow creators pay 0.01 USDC to 'borrow' your visual DNA for a single generation, with each transformation settled instantly on Hedera. Stop giving away your aesthetic for free; meter every prompt that touches your style. Why Hedera: By turning style files into a metered resource, we transition from 'preservation' (passive storage) to 'monetization' (active revenue). Every style application becomes a measurable micro-transaction, solving the problem of AI style theft by transforming it into an automated licensing model. Market: TAM $2.8B — The global generative AI content market, increasingly shifting toward authenticated and permissioned style usage. | SAM $140M — The emerging market for specialized AI models, LoRAs, and fine-tuning services. | SOM $12M — Independent digital artists on Hedera and Farcaster looking to monetize their proprietary 'look' through automated micro-licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Style Vault" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Secure your unique AI latent-space fingerprints on-chain. Style Vault allows artists to lock proprietary LoRA weights and style parameters behind a pay-per-inference gate. Fans and fellow creators pay 0.01 USDC to 'borrow' your visual DNA for a single generation, with each transformation settled instantly on Hedera. Stop giving away your aesthetic for free; meter every prompt that touches your style. Discipline: Visual Art (AI style preservation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning style files into a metered resource, we transition from 'preservation' (passive storage) to 'monetization' (active revenue). Every style application becomes a measurable micro-transaction, solving the problem of AI style theft by transforming it into an automated licensing model. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Style Vault" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-art-fair-digital-catalog-13-x402 Title: VERNISSAGE · x402 Theme: Visual Art (visual-art) · event art listings Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity digital archive for art fairs where collectors pay a nominal USDC fee to unlock the full 'Provenance Record' of featured works. Collectors pay 0.01 USDC to view high-res scans and pricing history, while agents and art-advisors pay to programmatically pull gallery rosters. Each unlock triggers a Base transaction, ensuring fair distribution to the hosting fair and featured artists. Why Hedera: Event catalogs are currently static PDFs or ad-cluttered sites. x402 turns every gallery booth into a micro-revenue node. By gating high-res assets behind a $0.01 sign-to-pay, it filters for high-intent leads and creates a perpetual royalty stream for the event organizer whenever the 'digital twin' of the fair is accessed. Market: TAM $67B — The global physical and digital art market, transitioning to on-chain provenance and discovery. | SAM $420M — The digital art commerce and subscription-based gallery software market. | SOM $12M — Transactional fees from top-tier global art fairs (Art Basel, Frieze) adopting micro-paywalls for digital VIP access. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VERNISSAGE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity digital archive for art fairs where collectors pay a nominal USDC fee to unlock the full 'Provenance Record' of featured works. Collectors pay 0.01 USDC to view high-res scans and pricing history, while agents and art-advisors pay to programmatically pull gallery rosters. Each unlock triggers a Base transaction, ensuring fair distribution to the hosting fair and featured artists. Discipline: Visual Art (event art listings). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Event catalogs are currently static PDFs or ad-cluttered sites. x402 turns every gallery booth into a micro-revenue node. By gating high-res assets behind a $0.01 sign-to-pay, it filters for high-intent leads and creates a perpetual royalty stream for the event organizer whenever the 'digital twin' of the fair is accessed. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VERNISSAGE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-generative-token-gallery-14-x402 Title: LUMINA · x402 Theme: Visual Art (visual-art) · tokenized generative art Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A micro-curation protocol where viewers pay $0.01 per minute to 'illuminate' generative art manifests. High-fidelity rendering isn't free; users stream micropayments to keep the art alive on-chain. Artists receive continuous revenue based on dwell time rather than single-sale speculation. Every 'Heartbeat' event is a 0.01 USDC transaction returned with a Hedera transaction id, proving the artwork was rendered for a specific viewer in real-time. Why Hedera: Traditional NFT galleries are static; x402 turns display into a metered utility. By requiring a micropayment to trigger the generative seed and maintain the render, the app creates a direct value link between attention and compute, making the 'view' a programmable economic event. Market: TAM $2.8B — The total addressable market for digital signage, online galleries, and generative assets powered by pay-per-view primitives. | SAM $480M — The share of digital art enthusiasts and collectors active on L2 networks comfortable with micropayment-gated content. | SOM $12M — The immediate market of high-frequency generative art collectors and DAO-governed virtual galleries on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LUMINA" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A micro-curation protocol where viewers pay $0.01 per minute to 'illuminate' generative art manifests. High-fidelity rendering isn't free; users stream micropayments to keep the art alive on-chain. Artists receive continuous revenue based on dwell time rather than single-sale speculation. Every 'Heartbeat' event is a 0.01 USDC transaction returned with a Hedera transaction id, proving the artwork was rendered for a specific viewer in real-time. Discipline: Visual Art (tokenized generative art). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT galleries are static; x402 turns display into a metered utility. By requiring a micropayment to trigger the generative seed and maintain the render, the app creates a direct value link between attention and compute, making the 'view' a programmable economic event. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LUMINA" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-illustration-rights-ledger-15-x402 Title: InkProof · x402 Theme: Visual Art (visual-art) · digital rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered licensing layer for illustrators. Instead of complex legal contracts, commercial usage is authorized per-impression or per-download via micropayments. Each 0.01 USDC payment triggers a Base transaction that serves as a cryptographically signed usage receipt, allowing creators to monetize high-volume, low-friction digital distribution. Why Hedera: By turning rights management into a real-time pay-per-use primitive, we eliminate the friction of traditional licensing for small-scale creators and AI training sets. Payment is the proof-of-license. Market: TAM $34B — Total global market for Intellectual Property Management and Digital Rights software by 2030. | SAM $1.2B — The market for 'Stock Photography & Illustration' moving toward real-time micro-licensing models. | SOM $45M — Niche focus on independent digital illustrators and AI collage artists on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered licensing layer for illustrators. Instead of complex legal contracts, commercial usage is authorized per-impression or per-download via micropayments. Each 0.01 USDC payment triggers a Base transaction that serves as a cryptographically signed usage receipt, allowing creators to monetize high-volume, low-friction digital distribution. Discipline: Visual Art (digital rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning rights management into a real-time pay-per-use primitive, we eliminate the friction of traditional licensing for small-scale creators and AI training sets. Payment is the proof-of-license. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-augmented-art-archive-16-x402 Title: Spatial Pin · x402 Theme: Visual Art (visual-art) · AR art content storage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-view AR spatial layers. Users pay 0.01 USDC to pull encrypted AR textures and geometry from IPFS for local rendering. Creators earn per 'eye-gaze' unlock, ensuring a durable economy for digital street art and immersive installations without ads or tracking. Why Hedera: By shifting from free storage to pay-per-retrieval, storage costs are offset by user engagement. x402 handles the high-frequency/low-value transactions needed to unlock high-res assets in real-time as a user moves through a physical space. Market: TAM $18B — The global spatial computing and AR metadata market. | SAM $450M — The creator economy for immersive assets (textures, shaders, 3D models) sold in micro-units. | SOM $12M — AR urban art tours and persistent virtual scavenger hunt overlays. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Spatial Pin" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-view AR spatial layers. Users pay 0.01 USDC to pull encrypted AR textures and geometry from IPFS for local rendering. Creators earn per 'eye-gaze' unlock, ensuring a durable economy for digital street art and immersive installations without ads or tracking. Discipline: Visual Art (AR art content storage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from free storage to pay-per-retrieval, storage costs are offset by user engagement. x402 handles the high-frequency/low-value transactions needed to unlock high-res assets in real-time as a user moves through a physical space. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Spatial Pin" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-permanent-sketchbook-hub-17-x402 Title: InkTrace · x402 Theme: Visual Art (visual-art) · digital sketch archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Archive a page. Every digital stroke is cryptographically timestamped and pinned to IPFS via an x402-metered write. Instead of a monthly subscription for storage, you pay only for the memories you commit to the chain. Your sketchbook becomes a verifiable, immutable ledger of artistic evolution where every entry is a paid proof-of-creation. Why Hedera: Moves from 'storage service' to 'metered archival.' By making the transaction the moment of commitment, the user treats each upload as a deliberate act of permanent IP. x402 allows this without the friction of 'gas' or 'monthly fees,' just a per-page micropayment. Market: TAM $2.1B — Global digital art software and IP protection market. | SAM $85M — Digital art provenance and decentralized storage for independent creators. | SOM $4.2M — Professional concept artists and illustrators utilizing Hedera testnet for low-cost, high-frequency IP logging. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Archive a page. Every digital stroke is cryptographically timestamped and pinned to IPFS via an x402-metered write. Instead of a monthly subscription for storage, you pay only for the memories you commit to the chain. Your sketchbook becomes a verifiable, immutable ledger of artistic evolution where every entry is a paid proof-of-creation. Discipline: Visual Art (digital sketch archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves from 'storage service' to 'metered archival.' By making the transaction the moment of commitment, the user treats each upload as a deliberate act of permanent IP. x402 allows this without the friction of 'gas' or 'monthly fees,' just a per-page micropayment. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-visual-storyboard-chain-18-x402 Title: Storyboard Ledger · x402 Theme: Visual Art (visual-art) · narrative art sequencing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A frame-by-frame narrative sequencer where every storyboard injection or metadata update is a 0.01 USDC event. Creators secure their visual logic on-chain, while collaborators or AI agents pay to 'unlock' the next sequence in the branch, turning narrative flow into a metered protocol. Why Hedera: By pricing the 'sequence' rather than the storage, you monetize the creative process. Narrative art becomes a set of paid state changes, ensuring every story beat is a micro-transactional milestone. Market: TAM $2.8B — The global animation and digital storytelling market shifting toward granular, component-based ownership. | SAM $450M — Independent storyboard artists, concept studios, and webtoon creators adopting web3 rails. | SOM $12M — Early adopters in the Base narrative-art ecosystem and decentralized animation pipelines. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Storyboard Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A frame-by-frame narrative sequencer where every storyboard injection or metadata update is a 0.01 USDC event. Creators secure their visual logic on-chain, while collaborators or AI agents pay to 'unlock' the next sequence in the branch, turning narrative flow into a metered protocol. Discipline: Visual Art (narrative art sequencing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By pricing the 'sequence' rather than the storage, you monetize the creative process. Narrative art becomes a set of paid state changes, ensuring every story beat is a micro-transactional milestone. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Storyboard Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-curator-s-immutable-log-19-x402 Title: PROVENANCE · x402 Theme: Visual Art (visual-art) · art exhibition curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-entry digital provenance for exhibition archives. Curators commit metadata, spatial layouts, and critical essays to the ledger. Each 'Seal' or 'Access' call costs 0.01 USDC, turning the curator’s expertise into a metered, immutable API for galleries and collectors. Why Hedera: By shifting from a static log to a pay-per-use primitive, curation becomes a billable service rather than a side effect. Galleries pay to verify the 'Curator's Stamp' in real-time, ensuring that metadata isn't just stored, but commercially validated. Market: TAM $1.1B — The global art appraisal and provenance market transitioning to real-time, on-chain verification agents. | SAM $45M — Professional digital art curators, independent galleries, and high-end NFT photography collectives requiring authenticated exhibition data. | SOM $1.2M — Specialized curators on Hedera using automated micropayments to certify virtual gallery rotations. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PROVENANCE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-entry digital provenance for exhibition archives. Curators commit metadata, spatial layouts, and critical essays to the ledger. Each 'Seal' or 'Access' call costs 0.01 USDC, turning the curator’s expertise into a metered, immutable API for galleries and collectors. Discipline: Visual Art (art exhibition curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a static log to a pay-per-use primitive, curation becomes a billable service rather than a side effect. Galleries pay to verify the 'Curator's Stamp' in real-time, ensuring that metadata isn't just stored, but commercially validated. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PROVENANCE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-visual-remix-repository-20-x402 Title: ROOTSCAN · x402 Theme: Visual Art (visual-art) · art remix documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Origin-tracing for the remix era. Pay 0.01 USDC to anchor a remix-to-source link on-chain or query the lineage of a visual asset. Every contribution creates a permanent, billable attribution trail, turning 'exposure' into a micro-revenue stream for the original creator every time their work is cited or branched. Why Hedera: By turning attribution into a pay-per-call primitive, we transform documentation from a chore into a micro-transactional economy where provenance is a paid service. Market: TAM $9.5B — The global digital asset management and intellectual property rights market. | SAM $420M — The digital art market and NFT metadata indexing sector. | SOM $12M — On-chain remix culture, generative AI attribution, and pro-artist licensing platforms. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ROOTSCAN" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Origin-tracing for the remix era. Pay 0.01 USDC to anchor a remix-to-source link on-chain or query the lineage of a visual asset. Every contribution creates a permanent, billable attribution trail, turning 'exposure' into a micro-revenue stream for the original creator every time their work is cited or branched. Discipline: Visual Art (art remix documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning attribution into a pay-per-call primitive, we transform documentation from a chore into a micro-transactional economy where provenance is a paid service. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ROOTSCAN" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-unique-print-provenance-21-x402 Title: ORIGIN · x402 Theme: Visual Art (visual-art) · limited edition prints Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Verify print authenticity and provenance history in real-time. Each scan or 'Proof of Ownership' lookup costs $0.01 USDC, preventing mass scraping of high-res certificates while ensuring a permanent ledger of chain-of-custody. Collectors pay to claim; flippers pay to verify. Why Hedera: Provenance is high-value but low-bandwidth data. By metering the verification process, you turn a passive certificate into an active, revenue-generating security primitive that discourages sybil-verification scripts. Market: TAM $5.2B — The global limited edition art and collectibles market moving toward digital verification. | SAM $450M — The addressable segment of digital-physical hybrid art and high-end collectible trading. | SOM $12M — Transactional volume from independent print studios and gallery verification APIs on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ORIGIN" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Verify print authenticity and provenance history in real-time. Each scan or 'Proof of Ownership' lookup costs $0.01 USDC, preventing mass scraping of high-res certificates while ensuring a permanent ledger of chain-of-custody. Collectors pay to claim; flippers pay to verify. Discipline: Visual Art (limited edition prints). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Provenance is high-value but low-bandwidth data. By metering the verification process, you turn a passive certificate into an active, revenue-generating security primitive that discourages sybil-verification scripts. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ORIGIN" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-artistic-collaboration-chain-22-x402 Title: CanvasHash · x402 Theme: Visual Art (visual-art) · joint creation records Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Sign to commit. Every pixel, brushstroke, or layer addition is an atomic settlement. Artists co-sign the metadata by triggering a micropayment, creating a cryptographic proof-of-contribution that cannot be spoofed. No payment, no valid entry in the chain. Why Hedera: Instead of a passive log, the x402 model forces a financial handshake for every edit. This eliminates 'credit-camping' and replaces manual attribution with a hard-coded ledger where the transaction hash is the proof of work. Market: TAM $2.4B — The global creative software market shifting toward verified provenance and multi-user asset production. | SAM $180M — The digital art sub-market (Procreate/Adobe users) migrating to collaborative, traceable 'Pro' workflows. | SOM $12M — Web3 native DAOs and 'cc0' remix communities requiring automated revenue/credit splits. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CanvasHash" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Sign to commit. Every pixel, brushstroke, or layer addition is an atomic settlement. Artists co-sign the metadata by triggering a micropayment, creating a cryptographic proof-of-contribution that cannot be spoofed. No payment, no valid entry in the chain. Discipline: Visual Art (joint creation records). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Instead of a passive log, the x402 model forces a financial handshake for every edit. This eliminates 'credit-camping' and replaces manual attribution with a hard-coded ledger where the transaction hash is the proof of work. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CanvasHash" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-visual-rights-archive-23-x402 Title: Provenance Gate · x402 Theme: Visual Art (visual-art) · copyright preservation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn every 'Right-Click Save' into a revenue event. A headless registry where artists sign visual proofs to the L2. Instead of a stagnant database, it’s a living gate: users or AI scrapers pay 0.01 USDC to verify a high-resolution provenance link or generate a signed 'Permission to Use' certificate. The payment protocol acts as the timestamp, making the settlement the legal proof. Why Hedera: By shifting from 'protection' to 'metered verification,' the archive becomes an active economic layer. x402 allows agents and platforms to programmatically clear usage rights for fractional amounts, automating the legal defense fund directly through the protocol. Market: TAM $4.2B — The global creative IP protection and copyright litigation market. | SAM $850M — The digital rights management (DRM) and stock image licensing market adapting to on-chain compliance. | SOM $12M — Independent digital illustrators and AI-training datasets seeking verifiable, low-cost usage clearances on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Provenance Gate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn every 'Right-Click Save' into a revenue event. A headless registry where artists sign visual proofs to the L2. Instead of a stagnant database, it’s a living gate: users or AI scrapers pay 0.01 USDC to verify a high-resolution provenance link or generate a signed 'Permission to Use' certificate. The payment protocol acts as the timestamp, making the settlement the legal proof. Discipline: Visual Art (copyright preservation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'protection' to 'metered verification,' the archive becomes an active economic layer. x402 allows agents and platforms to programmatically clear usage rights for fractional amounts, automating the legal defense fund directly through the protocol. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Provenance Gate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-dynamic-exhibit-snapshot-24-x402 Title: Provenance Pulse · x402 Theme: Visual Art (visual-art) · exhibit state recording Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-capture archival tool for curators and galleries. Each high-fidelity spatial recording is committed to the Base ledger via a 0.01 USDC micropayment. Secure a verifiable provenance trail for temporary installations, lighting states, and spatial arrangements before they vanish. Why Hedera: By turning each 'state record' into a paid transaction, the act of archiving gains economic weight and permanence. It prevents database bloat and ensures that only high-value snapshots are written to the chain, creating a premium historical ledger for the art market. Market: TAM $850M — The global art documentation and archival insurance market. | SAM $45M — Professional galleries, NFT platforms, and independent curators seeking verifiable provenance. | SOM $1.2M — On-chain galleries and digital-physical hybrid exhibits on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Provenance Pulse" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-capture archival tool for curators and galleries. Each high-fidelity spatial recording is committed to the Base ledger via a 0.01 USDC micropayment. Secure a verifiable provenance trail for temporary installations, lighting states, and spatial arrangements before they vanish. Discipline: Visual Art (exhibit state recording). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning each 'state record' into a paid transaction, the act of archiving gains economic weight and permanence. It prevents database bloat and ensures that only high-value snapshots are written to the chain, creating a premium historical ledger for the art market. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Provenance Pulse" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-canvas-collaborate-0-x402 Title: Stroke/Sum · x402 Theme: Visual Art (visual-art) · collaborative painting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A collaborative digital canvas where every brushstroke is a microscopic financial event. Instead of a free-for-all, artists pay $0.01 USDC per stroke to contribute, turning the final piece into a map of economic and creative density. All proceeds accrue to a pool shared by contributors or a featured charity, settled instantly via the embedded wallet-signed HTS transfer auth. Payment isn't just a fee; it's the sybil-resistance and the medium itself. Why Hedera: By pricing the 'stroke' at one cent, we eliminate spam, increase the intentionality of each mark, and create a high-velocity transaction environment that benefits from x402’s gasless facilitator model. Market: TAM $4.5B — The global creative software market evolving into the 'Internet of Value' where every digital interaction is metered. | SAM $140M — The addressable market for digital art software and collaborative tooling (Figma/Canva) for the Web3 creator economy. | SOM $5.2M — Targeted at the 'Thousand True Fans' segment of crypto-native digital painters and DAO-based art collectives. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stroke/Sum" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A collaborative digital canvas where every brushstroke is a microscopic financial event. Instead of a free-for-all, artists pay $0.01 USDC per stroke to contribute, turning the final piece into a map of economic and creative density. All proceeds accrue to a pool shared by contributors or a featured charity, settled instantly via the embedded wallet-signed HTS transfer auth. Payment isn't just a fee; it's the sybil-resistance and the medium itself. Discipline: Visual Art (collaborative painting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By pricing the 'stroke' at one cent, we eliminate spam, increase the intentionality of each mark, and create a high-velocity transaction environment that benefits from x402’s gasless facilitator model. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stroke/Sum" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-illustrator-guild-1-x402 Title: InkGate · x402 Theme: Visual Art (visual-art) · illustrator community Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity asset vault where every high-res export, brush-set download, and time-lapse replay is metered. Instead of subscriptions, fans and fellow artists pay 0.01 USDC to 'Unlock Canvas' or 'Peel Layer.' Pro illustrators set per-view micro-fees for their process videos, turning their archive into a passive stream of machine-verifiable income. Integration with the embedded wallet allows zero-friction signatures, letting collectors 'tip to reveal' hidden drafts instantly. Why Hedera: Moving from a 'Guild' (community) to a 'Metered Vault' (utility) turns the creative process into a liquidity event. By pricing granular actions like 'View Layers' at $0.01, the barrier to entry for fans is lowered while the cumulative revenue for the artist is uncapped. Market: TAM $2.8B — The global digital illustration and creative software market, shifting toward micro-monetization and verifiable provenance. | SAM $450M — The digital asset, brush, and texture marketplace for professional concept artists and freelancers. | SOM $12M — Early adopters in the 'Process-as-a-Product' niche, charging for raw file access and time-lapse tutorials. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity asset vault where every high-res export, brush-set download, and time-lapse replay is metered. Instead of subscriptions, fans and fellow artists pay 0.01 USDC to 'Unlock Canvas' or 'Peel Layer.' Pro illustrators set per-view micro-fees for their process videos, turning their archive into a passive stream of machine-verifiable income. Integration with the embedded wallet allows zero-friction signatures, letting collectors 'tip to reveal' hidden drafts instantly. Discipline: Visual Art (illustrator community). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a 'Guild' (community) to a 'Metered Vault' (utility) turns the creative process into a liquidity event. By pricing granular actions like 'View Layers' at $0.01, the barrier to entry for fans is lowered while the cumulative revenue for the artist is uncapped. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-generative-canvas-2-x402 Title: GenPress · x402 Theme: Visual Art (visual-art) · generative art Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless canvas for generative brushes. Artists deploy scripts; users pay 0.01 USDC to trigger a 'render' call. Each HTS transfer signature generates a unique, deterministic seed on-chain. Pay-per-stroke or pay-per-high-res-export, with the facilitator settling the artist's royalties instantly to their Magic Link email sign-in. Use our API to embed living art into any site, metered by the view. Why Hedera: x402 transforms generative art from a one-time NFT sale into a recurring service. By gating the 'render' function, artists monetize the execution of their code, not just the static output. This allows for 'streaming' art where every frame or interaction is a micro-transaction. Market: TAM $2.8B — Global digital art and collectibles market, including the rising 'Agentic Art' sector where AI bots buy visual assets. | SAM $450M — Revenue from generative art platforms and creative coding licenses transitioning to micropayment models. | SOM $12M — The market for programmatic, metered UI elements and dynamic digital signage powered by Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GenPress" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless canvas for generative brushes. Artists deploy scripts; users pay 0.01 USDC to trigger a 'render' call. Each HTS transfer signature generates a unique, deterministic seed on-chain. Pay-per-stroke or pay-per-high-res-export, with the facilitator settling the artist's royalties instantly to their Magic Link email sign-in. Use our API to embed living art into any site, metered by the view. Discipline: Visual Art (generative art). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: x402 transforms generative art from a one-time NFT sale into a recurring service. By gating the 'render' function, artists monetize the execution of their code, not just the static output. This allows for 'streaming' art where every frame or interaction is a micro-transaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GenPress" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-gallery-ledger-3-x402 Title: Veritas · x402 Theme: Visual Art (visual-art) · art provenance tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An immutable fingerprinting service for fine art. Collectors and galleries pay 0.01 USDC to 'Ping' a piece, returning its cryptographically signed provenance history and physical custody log. Payment isn't just a fee; it's the heartbeat of the audit trail, ensuring every title search and authenticity check is recorded on Hedera. Why Hedera: Moving from a free ledger to a pay-per-lookup model prevents sybil data scraping and establishes a 'fee-per-audit' standard for high-value assets. HTS transfer allows galleries to sign checks without holding gas, perfect for tablet-based showroom kiosks. Market: TAM $67B — The global art market provenance and logistics industry moving to automated, onchain verification. | SAM $850M — The secondary art market and mid-tier galleries requiring digital certification for transport and insurance. | SOM $22M — Early adopter galleries on Hedera and digital-twin luxury physical assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Veritas" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An immutable fingerprinting service for fine art. Collectors and galleries pay 0.01 USDC to 'Ping' a piece, returning its cryptographically signed provenance history and physical custody log. Payment isn't just a fee; it's the heartbeat of the audit trail, ensuring every title search and authenticity check is recorded on Hedera. Discipline: Visual Art (art provenance tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a free ledger to a pay-per-lookup model prevents sybil data scraping and establishes a 'fee-per-audit' standard for high-value assets. HTS transfer allows galleries to sign checks without holding gas, perfect for tablet-based showroom kiosks. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Veritas" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-palette-swap-4-x402 Title: Pigment · x402 Theme: Visual Art (visual-art) · color exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — inject heritage into your canvas. A peer-to-peer hex-code market where every 'Apply Palette' action triggers a direct USDC settlement to the original color theorist. No subscriptions, just sub-cent micro-licensing for professional illustrators and generative artists seeking high-fidelity aesthetics. Use the HTS transfer flow to instantly unlock unique .ASE and .CLR files via signed intent. Why Hedera: By commoditizing the 'Palette' at the sub-cent level, we transition from 'free inspiration' to a paid 'style-injection' economy. x402 handles the high-volume, low-value nature of color data that traditional gas fees would kill. Market: TAM $2.4B — The global creative software and digital asset ecosystem moving toward atomized ownership. | SAM $180M — The digital illustration and UI/UX design asset market. | SOM $12M — On-chain generative artists and procreators using automated style-transfer tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Pigment" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — inject heritage into your canvas. A peer-to-peer hex-code market where every 'Apply Palette' action triggers a direct USDC settlement to the original color theorist. No subscriptions, just sub-cent micro-licensing for professional illustrators and generative artists seeking high-fidelity aesthetics. Use the HTS transfer flow to instantly unlock unique .ASE and .CLR files via signed intent. Discipline: Visual Art (color exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By commoditizing the 'Palette' at the sub-cent level, we transition from 'free inspiration' to a paid 'style-injection' economy. x402 handles the high-volume, low-value nature of color data that traditional gas fees would kill. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Pigment" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-sketchstream-5-x402 Title: INKFLOW · x402 Theme: Visual Art (visual-art) · live drawing streams Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Each stroke is a transaction. Viewers pay $0.01 per minute of live stream access or $0.05 to unlock a high-res JPG export of the current canvas state via the embedded wallet-signed HTS transfer. Artists receive instant settlement as they draw, moving from 'hopeful tipping' to 'granular metering'. Why Hedera: Current streaming models rely on platform-heavy subscriptions or infrequent, large donations. Reframing the stream as a metered asset allows for high-frequency, low-friction settlement where the audience pays exactly for their dwell time and specific interactions. Market: TAM The global live-streaming and content creator economy. ($250B) | SAM Digital art enthusiasts and collectors on Hedera using embedded wallets. ($450M) | SOM Live-sketching community and speed-painting niche transitioning to pay-per-view models. ($12M) ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "INKFLOW" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Each stroke is a transaction. Viewers pay $0.01 per minute of live stream access or $0.05 to unlock a high-res JPG export of the current canvas state via the embedded wallet-signed HTS transfer. Artists receive instant settlement as they draw, moving from 'hopeful tipping' to 'granular metering'. Discipline: Visual Art (live drawing streams). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current streaming models rely on platform-heavy subscriptions or infrequent, large donations. Reframing the stream as a metered asset allows for high-frequency, low-friction settlement where the audience pays exactly for their dwell time and specific interactions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "INKFLOW" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-nft-splitter-6-x402 Title: Fractional · x402 Theme: Visual Art (visual-art) · fractional art ownership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn physical galleries into metered interactive experiences. Pay 0.01 USDC to secure a micro-fraction of a masterpiece's revenue rights with a single click. Every gaze, scan, or digital derivative payout flows back to holders via high-frequency micropayment streams. Use the embedded wallet to sign into the canvas and x402 to stream ownership instantly. Why Hedera: Redefines ownership from a monolithic 'buy and hold' event to a 'pay-per-fraction' utility. By using x402, the barrier to entry for fine art is lowered to the absolute minimum (one cent), enabling high-velocity secondary turnover and instant distribution of royalties without gas friction. Market: TAM $67B — Global Fine Art market asset value. | SAM $1.2B — Emerging 'Fractional Art & RWA' on-chain market. | SOM $15M — Base-native digital art collectors and mobile-first retail investors. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Fractional" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn physical galleries into metered interactive experiences. Pay 0.01 USDC to secure a micro-fraction of a masterpiece's revenue rights with a single click. Every gaze, scan, or digital derivative payout flows back to holders via high-frequency micropayment streams. Use the embedded wallet to sign into the canvas and x402 to stream ownership instantly. Discipline: Visual Art (fractional art ownership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Redefines ownership from a monolithic 'buy and hold' event to a 'pay-per-fraction' utility. By using x402, the barrier to entry for fine art is lowered to the absolute minimum (one cent), enabling high-velocity secondary turnover and instant distribution of royalties without gas friction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Fractional" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-artswap-social-7-x402 Title: CanvasDraft · x402 Theme: Visual Art (visual-art) · art barter communities Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-velocity creative economy where every appreciation is a settlement. Instead of 'swapping' via trust-based DMs, artists offer 'Locked Drafts' or 'Layer Access' for 0.01 USDC. The friction of gas is replaced by a signature-based stream: pay-per-view high-res source files or pay-per-critique. This turns art discovery into a series of micro-grants that build a reputation score based on liquidity rather than just likes. Fees are handled off-chain via the embedded wallet, settled on Hedera. Why Hedera: Traditional barter is inefficient due to the 'double coincidence of wants.' x402 creates a common unit of account for creators (0.01 USDC) that allows for asynchronous bartering. An artist 'buys' a brush set from one peer and 'sells' a sketch to another in seconds. Market: TAM $2.1B — Total addressable market for global digital art asset exchange and micro-licensing. | SAM $450M — The creative professional and freelance marketplace segment on Hedera. | SOM $12M — Series-based digital illustrators and asset-flipping artists. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CanvasDraft" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-velocity creative economy where every appreciation is a settlement. Instead of 'swapping' via trust-based DMs, artists offer 'Locked Drafts' or 'Layer Access' for 0.01 USDC. The friction of gas is replaced by a signature-based stream: pay-per-view high-res source files or pay-per-critique. This turns art discovery into a series of micro-grants that build a reputation score based on liquidity rather than just likes. Fees are handled off-chain via the embedded wallet, settled on Hedera. Discipline: Visual Art (art barter communities). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional barter is inefficient due to the 'double coincidence of wants.' x402 creates a common unit of account for creators (0.01 USDC) that allows for asynchronous bartering. An artist 'buys' a brush set from one peer and 'sells' a sketch to another in seconds. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CanvasDraft" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-sketchvault-8-x402 Title: SovereignDraw · x402 Theme: Visual Art (visual-art) · secure sketch storage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity archival layer for digital artists. Pay 0.01 USDC to time-stamp, encrypt, and commit a sketch to the vault via the embedded wallet. Grant temporary viewing access to collectors or collaborators for a single micropayment per session. No subscriptions, just a secure ledger of your creative process. Why Hedera: Sketching is high-volume but low individual value. x402 turns the 'save' button into a sovereign act of provenance. It moves storage from a cost-center for the dev to a revenue-per-action model for the artist. Market: TAM $2.1B — The global digital art and collectibles market transitioning to onchain provenance and micro-licensing. | SAM $120M — Digital illustrators and concept artists using iPad/Wacom-native workflows needing low-friction attribution. | SOM $4.5M — Early adopters on Hedera using frame-based art galleries and Farcaster-integrated creative tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SovereignDraw" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity archival layer for digital artists. Pay 0.01 USDC to time-stamp, encrypt, and commit a sketch to the vault via the embedded wallet. Grant temporary viewing access to collectors or collaborators for a single micropayment per session. No subscriptions, just a secure ledger of your creative process. Discipline: Visual Art (secure sketch storage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Sketching is high-volume but low individual value. x402 turns the 'save' button into a sovereign act of provenance. It moves storage from a cost-center for the dev to a revenue-per-action model for the artist. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SovereignDraw" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-brushstroke-rights-9-x402 Title: Vernissage · x402 Theme: Visual Art (visual-art) · digital rights management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn digital art into a metered asset. Every time a designer previews a high-res asset, references a style, or transfers a commercial license, a 0.01 USDC x402 stream settles instantly. No subscriptions, just sub-cent payments triggered by the act of creation. Creators get paid for every 'look,' and users pay only for the pixels they use. Why Hedera: Current DRM is binary (bought/not bought). x402 introduces 'granular licensing'—allowing artists to monetize the friction of previewing and the finality of rights transfer through high-velocity micropayments, reducing barrier to entry for buyers. Market: TAM $4.5B — The global digital asset management and stock imagery market shifting towards micro-licensing. | SAM $800M — The addressable market for independent illustrators and small-scale digital agencies transitioning to onchain workflows. | SOM $12M — Transaction volume from 100k active licenses generating an average of 10 micropayment events (previews/verifications) per day. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vernissage" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn digital art into a metered asset. Every time a designer previews a high-res asset, references a style, or transfers a commercial license, a 0.01 USDC x402 stream settles instantly. No subscriptions, just sub-cent payments triggered by the act of creation. Creators get paid for every 'look,' and users pay only for the pixels they use. Discipline: Visual Art (digital rights management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current DRM is binary (bought/not bought). x402 introduces 'granular licensing'—allowing artists to monetize the friction of previewing and the finality of rights transfer through high-velocity micropayments, reducing barrier to entry for buyers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vernissage" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-artchain-auctions-10-x402 Title: Gavel · x402 Theme: Visual Art (visual-art) · auction facilitation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency auction engine where bidding isn't just a gesture, but a micro-transactional commitment. Bidders authorize $0.01 USDC per bid via x402, eliminating gas volatility while ensuring 'skin in the game.' Artists receive real-time streaming settlement as the hammer price climbs. Built for 1-of-1 digital artifacts and physical-linked NFTs where every interaction contributes to the final bounty. Why Hedera: Traditional auctions suffer from high barrier-to-entry (gas) or 'ghost bidding.' By making every bid a $0.01 HTS transfer payment, we create a high-velocity, low-friction environment where human and AI bidders compete in real-time without signing complex pop-ups or holding ETH. Market: TAM $67B — The global art market transitioning to verifiable, high-frequency digital exchange. | SAM $420M — Digital art collectors and DAOs using L2-native auction platforms. | SOM $12M — Emerging visual artists using HashPack-based gallery tools on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Gavel" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency auction engine where bidding isn't just a gesture, but a micro-transactional commitment. Bidders authorize $0.01 USDC per bid via x402, eliminating gas volatility while ensuring 'skin in the game.' Artists receive real-time streaming settlement as the hammer price climbs. Built for 1-of-1 digital artifacts and physical-linked NFTs where every interaction contributes to the final bounty. Discipline: Visual Art (auction facilitation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional auctions suffer from high barrier-to-entry (gas) or 'ghost bidding.' By making every bid a $0.01 HTS transfer payment, we create a high-velocity, low-friction environment where human and AI bidders compete in real-time without signing complex pop-ups or holding ETH. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Gavel" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-framefi-marketplace-11-x402 Title: Lume · x402 Theme: Visual Art (visual-art) · digital framing services Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Gallery-grade digital matting and AR framing as a metered service. Creators embed a 'Lume' call into their mints; collectors pay $0.01 USDC per viewing session to render the piece in a high-fidelity, environment-aware digital frame. No monthly subscriptions for collectors—just a penny to see the art properly dressed for the screen. Why Hedera: By pivoting from a 'marketplace' to a 'metered rendering service,' the payment becomes the primitive for visual fidelity. Moving the cost to a pay-per-render model allows artists to offer 'free' art that generates continuous micro-yield every time a collector displays or showcases the work. Market: TAM $2.4B — The global digital signage and NFT frame hardware ecosystem. | SAM $180M — The digital art display and AR home-decor software segment. | SOM $12M — On-chain illustrators and Base-native collectors looking for high-end display utility. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Lume" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Gallery-grade digital matting and AR framing as a metered service. Creators embed a 'Lume' call into their mints; collectors pay $0.01 USDC per viewing session to render the piece in a high-fidelity, environment-aware digital frame. No monthly subscriptions for collectors—just a penny to see the art properly dressed for the screen. Discipline: Visual Art (digital framing services). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By pivoting from a 'marketplace' to a 'metered rendering service,' the payment becomes the primitive for visual fidelity. Moving the cost to a pay-per-render model allows artists to offer 'free' art that generates continuous micro-yield every time a collector displays or showcases the work. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Lume" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-mural-mesh-12-x402 Title: PixelWall · x402 Theme: Visual Art (visual-art) · public mural collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Coordinate world-class public murals by enabling artists to claim physical grid coordinates via sub-cent micropayments. Each 0.01 USDC transaction locks a tile, triggers a localized task notification (e.g., 'Paint Prime Blue here'), and permanently anchors that stroke's metadata to the global map. Payments act as the consensus mechanism for space-time allocation on the wall. Why Hedera: By moving from 'free collaboration' to 'pay-per-claim', you eliminate sybil attacks on public space. The 0.01 USDC fee functions as a digital deposit for physical action, ensuring every artist 'skin in the game' for their specific coordinate. Market: TAM $3.2B — Global outdoor advertising and public art installation market moving toward decentralized coordination. | SAM $450M — The shared-economy creator market and urban street-art tech sector. | SOM $12M — Onchain mural festivals and hyper-local collaborative street art commissions using Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PixelWall" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Coordinate world-class public murals by enabling artists to claim physical grid coordinates via sub-cent micropayments. Each 0.01 USDC transaction locks a tile, triggers a localized task notification (e.g., 'Paint Prime Blue here'), and permanently anchors that stroke's metadata to the global map. Payments act as the consensus mechanism for space-time allocation on the wall. Discipline: Visual Art (public mural collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'free collaboration' to 'pay-per-claim', you eliminate sybil attacks on public space. The 0.01 USDC fee functions as a digital deposit for physical action, ensuring every artist 'skin in the game' for their specific coordinate. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PixelWall" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-colorstory-13-x402 Title: Chromapane · x402 Theme: Visual Art (visual-art) · color narrative art Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-hue. A canvas where narrative depth is gated by the spectrum. Illustrators publish grayscale drafts; users unlock specific color layers and narrative shifts in real-time. Each tap to 'fill' a scene or progress the palette triggers a 0.01 USDC HTS transfer transfer. The artist is paid per impression and per immersion, turning visual storytelling into a metered, high-fidelity experience for collectors and AI training sets looking for human emotional-color mapping. Why Hedera: Moving from static NFTs to 'pay-per-reveal' mechanics turns the aesthetic experience into a high-frequency revenue stream. By metering the color evolution, we create a new primitive for digital art consumption where the audience pays to witness the metamorphosis. Market: TAM $2.4B — The global creative economy moving toward granular, per-interaction payment models (The 'Creator Micropayment' layer). | SAM $140M — The digital illustration and web-comic market transitioning to micro-transactional monetization models. | SOM $8M — Base-native art collectors and mobile users utilizing HashPack-integrated social apps for friction-less content unlocking. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chromapane" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-hue. A canvas where narrative depth is gated by the spectrum. Illustrators publish grayscale drafts; users unlock specific color layers and narrative shifts in real-time. Each tap to 'fill' a scene or progress the palette triggers a 0.01 USDC HTS transfer transfer. The artist is paid per impression and per immersion, turning visual storytelling into a metered, high-fidelity experience for collectors and AI training sets looking for human emotional-color mapping. Discipline: Visual Art (color narrative art). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from static NFTs to 'pay-per-reveal' mechanics turns the aesthetic experience into a high-frequency revenue stream. By metering the color evolution, we create a new primitive for digital art consumption where the audience pays to witness the metamorphosis. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chromapane" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-token-tapestry-14-x402 Title: Warp & Weft · x402 Theme: Visual Art (visual-art) · textile pattern art Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: High-fidelity textile generation. Pay 0.01 USDC to unlock an SVG vector export of a unique generative knit pattern. Each transaction triggers a micro-royalty to the original weaver-coder, enabling a continuous 'Infinite Loom' where every user-interaction is a direct settlement for digital craftsmanship. Why Hedera: Reframes the one-off 'minting' model into a metered, utility-based micro-payment system. The app becomes a vending machine for design assets rather than a high-friction NFT marketplace. Market: TAM $3.6B — Global smart-textile and CAD pattern licensing market. | SAM $480M — The digital textile and interior design asset market. | SOM $12M — Web3-native fashion designers and procedural art collectors on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Warp & Weft" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT High-fidelity textile generation. Pay 0.01 USDC to unlock an SVG vector export of a unique generative knit pattern. Each transaction triggers a micro-royalty to the original weaver-coder, enabling a continuous 'Infinite Loom' where every user-interaction is a direct settlement for digital craftsmanship. Discipline: Visual Art (textile pattern art). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Reframes the one-off 'minting' model into a metered, utility-based micro-payment system. The app becomes a vending machine for design assets rather than a high-friction NFT marketplace. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Warp & Weft" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-gallery-ghost-15-x402 Title: Ghost · x402 Theme: Visual Art (visual-art) · virtual gallery hosting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A spatial curation engine where every 'step' or 'view' triggers a micro-transaction. $0.01 USDC per work viewed via x402, paid instantly through the embedded wallet. Gallerists earn real-time streaming revenue as visitors navigate, replacing flat tickets with precise, metered engagement. Guests only pay for what they look at; curators get paid for every second of attention. Why Hedera: By moving from a flat ticket to pay-per-view-event (metered at the interaction level), the app aligns artist incentives with visitor duration. x402 allows for granular 'attention-rent' that was previously impossible. Market: TAM $65B — The global art market, transitioning toward autonomous, per-view digital monetization. | SAM $450M — Virtual art sales and 3D digital event markets shifting to micropayment models. | SOM $12M — Web3-native galleries and digital art collectives on Hedera using metered access. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Ghost" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A spatial curation engine where every 'step' or 'view' triggers a micro-transaction. $0.01 USDC per work viewed via x402, paid instantly through the embedded wallet. Gallerists earn real-time streaming revenue as visitors navigate, replacing flat tickets with precise, metered engagement. Guests only pay for what they look at; curators get paid for every second of attention. Discipline: Visual Art (virtual gallery hosting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a flat ticket to pay-per-view-event (metered at the interaction level), the app aligns artist incentives with visitor duration. x402 allows for granular 'attention-rent' that was previously impossible. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Ghost" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-brushbot-16-x402 Title: BrushBot · x402 Theme: Visual Art (visual-art) · AI-assisted painting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Stroke-by-stroke autonomy. BrushBot meters every AI-assisted refinement, texture layer, and brush stroke as a discrete HTS transfer micro-settlement. Stop paying for monthly SaaS subscriptions you don't use; pay only for the exact latency and compute consumed by your digital canvas. High-fidelity diffusion calls are gated by instant the embedded wallet-signed auth, turning the act of painting into a real-time stream of provenance-backed micro-transactions. Why Hedera: Traditional AI art tools suffer from 'subscription fatigue' or credit systems that obfuscate cost. x402 allows for granular, per-stroke billing, ensuring creators only pay for the specific AI interventions they trigger while providing immediate onchain proof of work for every layer added to the piece. Market: TAM $4.5B — The global digital art and automated image synthesis market, increasingly dominated by per-inference billing models. | SAM $850M — The projected market for AI-integrated creative design software and API-driven generative toolkits. | SOM $12M — Early adopters in the Base ecosystem and digital fine artists transitioning from flat subscriptions to metered, high-frequency tool usage. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "BrushBot" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Stroke-by-stroke autonomy. BrushBot meters every AI-assisted refinement, texture layer, and brush stroke as a discrete HTS transfer micro-settlement. Stop paying for monthly SaaS subscriptions you don't use; pay only for the exact latency and compute consumed by your digital canvas. High-fidelity diffusion calls are gated by instant the embedded wallet-signed auth, turning the act of painting into a real-time stream of provenance-backed micro-transactions. Discipline: Visual Art (AI-assisted painting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional AI art tools suffer from 'subscription fatigue' or credit systems that obfuscate cost. x402 allows for granular, per-stroke billing, ensuring creators only pay for the specific AI interventions they trigger while providing immediate onchain proof of work for every layer added to the piece. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "BrushBot" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-sketchset-swap-17-x402 Title: Bristle · x402 Theme: Visual Art (visual-art) · brush preset exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity brush stroke marketplace where digital artists pay $0.01 USDC to instantly 'Dip' their stylus into premium preset libraries. Powered by HTS transfer, creators earn instantly per-stroke or per-download, transforming brushes from static assets into metered utility tools for professional workflows. Why Hedera: By shifting from a one-time purchase to a metered 'pay-per-unlock' or 'pay-per-usage' model, we capture the long-tail value of individual assets and allow for micro-licensing that didn't exist before. Market: TAM $3.8B — The global digital art software and creator economy asset market. | SAM $420M — Professional digital illustrators and concept artists using tablet-based software. | SOM $12M — Procreate and Photoshop power-users on Hedera utilizing HashPack-integrated marketplaces. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Bristle" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity brush stroke marketplace where digital artists pay $0.01 USDC to instantly 'Dip' their stylus into premium preset libraries. Powered by HTS transfer, creators earn instantly per-stroke or per-download, transforming brushes from static assets into metered utility tools for professional workflows. Discipline: Visual Art (brush preset exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a one-time purchase to a metered 'pay-per-unlock' or 'pay-per-usage' model, we capture the long-tail value of individual assets and allow for micro-licensing that didn't exist before. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Bristle" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-artchain-critique-18-x402 Title: Proof of Palette · x402 Theme: Visual Art (visual-art) · peer review platform Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A brutalist review floor where every critique is a micro-transaction. Pay 0.01 USDC to unlock an expert critique or stake 0.01 USDC to provide a review that converts to reputation-weighted yield. Artists pay only for the feedback they consume; reviewers earn per word. No gas, just raw value exchange. Why Hedera: By replacing 'free' feedback with x402 micropayments, we eliminate low-effort spam and incentivize high-quality technical analysis. The 0.01 USDC primitive turns peer review into a high-velocity service economy rather than a social network chore. Market: TAM $850M — The global art education and professional feedback market shifting toward micro-consultancy. | SAM $45M — Professional digital artists and art students seeking fast, meritocratic feedback. | SOM $1.2M — Base-native creators and prompt engineers using HashPack to streamline critique workflows. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Proof of Palette" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A brutalist review floor where every critique is a micro-transaction. Pay 0.01 USDC to unlock an expert critique or stake 0.01 USDC to provide a review that converts to reputation-weighted yield. Artists pay only for the feedback they consume; reviewers earn per word. No gas, just raw value exchange. Discipline: Visual Art (peer review platform). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing 'free' feedback with x402 micropayments, we eliminate low-effort spam and incentivize high-quality technical analysis. The 0.01 USDC primitive turns peer review into a high-velocity service economy rather than a social network chore. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Proof of Palette" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-illustrate-impact-19-x402 Title: PatronStream · x402 Theme: Visual Art (visual-art) · art philanthropy Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol where causes pay illustrators via micro-bounties. Every view, high-res download, or social unlock of an advocacy graphic triggers a 0.01 USDC transfer to the artist. 'Illustrate Impact' becomes a metered engine for visual activism where supporters don't just 'donate'—they pay-per-pixel to fuel the artist's runway, settled instantly on Hedera. Why Hedera: By shifting from 'donations' to 'micro-metered usage,' we turn philanthropy into a sustainable income stream. The x402 model ensures artists are paid for the actual reach and utility of their work, rather than relying on one-off altruism. Market: TAM $2.8B — The global social impact design and digital donation market. | SAM $450M — The digital advocacy and non-profit design sector shifting to transparent, on-chain settlement. | SOM $12M — Independent activist-illustrators on Hedera using micropayments for asset distribution. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PatronStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol where causes pay illustrators via micro-bounties. Every view, high-res download, or social unlock of an advocacy graphic triggers a 0.01 USDC transfer to the artist. 'Illustrate Impact' becomes a metered engine for visual activism where supporters don't just 'donate'—they pay-per-pixel to fuel the artist's runway, settled instantly on Hedera. Discipline: Visual Art (art philanthropy). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'donations' to 'micro-metered usage,' we turn philanthropy into a sustainable income stream. The x402 model ensures artists are paid for the actual reach and utility of their work, rather than relying on one-off altruism. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PatronStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-colorchain-auctions-20-x402 Title: Chromasettlev · x402 Theme: Visual Art (visual-art) · color-themed art auctions Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Bid on generative color palettes and hex-coded digital assets where every bid is a 0.01 USDC x402 micropayment. Instead of high-friction gas wars, users 'ink' their interest through streaming micropayments that fund the artist in real-time. Each increment settles on Hedera, providing proof-of-bid without the overhead of traditional auction houses. Why Hedera: By turning the 'bid' into a recurring micropayment, we solve the friction of traditional auctions. The payment becomes the interaction primitive—proving stake and interest without committing to thousands of dollars upfront. Market: TAM $65B — The global fine art and digital collectibles market. | SAM $400M — On-chain generative art and NFT auction volume on Hedera/L2s. | SOM $12M — Micro-collectors and high-frequency bidders seeking low-friction participation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chromasettlev" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Bid on generative color palettes and hex-coded digital assets where every bid is a 0.01 USDC x402 micropayment. Instead of high-friction gas wars, users 'ink' their interest through streaming micropayments that fund the artist in real-time. Each increment settles on Hedera, providing proof-of-bid without the overhead of traditional auction houses. Discipline: Visual Art (color-themed art auctions). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the 'bid' into a recurring micropayment, we solve the friction of traditional auctions. The payment becomes the interaction primitive—proving stake and interest without committing to thousands of dollars upfront. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chromasettlev" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-illustrator-ink-21-x402 Title: Sumi-E · x402 Theme: Visual Art (visual-art) · digital ink art Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A digital calligraphy and brush-stroke protocol where every stroke is a micro-transaction. Pay 0.01 USDC to unlock 'Master Strokes'—high-fidelity, signature brush textures designed by elite ink artists. Instead of buying a static piece, creators meter access to their unique digital ink 'DNA'. Artists receive immediate HTS transfer settlements as users paint, turning the act of digital creation into a real-time revenue stream for the toolsmith. Why Hedera: Traditional 'minting' is too slow for the creative process. By moving the payment layer to the individual brush/asset level (x402), we turn digital ink into a consumable utility. This creates a recurring revenue model for artists who develop unique algorithmic brushes, paid for by the creators who use them. Market: TAM $2.6B — The global digital art software and asset marketplace economy, increasingly driven by modular, metered AI-collaborative tools. | SAM $480M — The creative professional software market shifting toward micro-licensing and pay-per-use assets. | SOM $12M — Web3-native digital illustrators and ink artists on Hedera using HTS transfer for asset-gated brush libraries. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sumi-E" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A digital calligraphy and brush-stroke protocol where every stroke is a micro-transaction. Pay 0.01 USDC to unlock 'Master Strokes'—high-fidelity, signature brush textures designed by elite ink artists. Instead of buying a static piece, creators meter access to their unique digital ink 'DNA'. Artists receive immediate HTS transfer settlements as users paint, turning the act of digital creation into a real-time revenue stream for the toolsmith. Discipline: Visual Art (digital ink art). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional 'minting' is too slow for the creative process. By moving the payment layer to the individual brush/asset level (x402), we turn digital ink into a consumable utility. This creates a recurring revenue model for artists who develop unique algorithmic brushes, paid for by the creators who use them. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sumi-E" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-generative-gallery-22-x402 Title: PRISM · x402 Theme: Visual Art (visual-art) · algorithmic art exhibition Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-frame curation. A headless gallery where every algorithmic seed generation requires a micro-payment. Collectors don't buy tickets; they pay to 'evolve' the art in real-time. Each 0.01 USDC call triggers a new parameter shift in the exhibition's global shader, settling instantly on Hedera. The gallery state is a collective stream of paid mutations. Why Hedera: Moves from passive 'ticketing' to active 'metered participation.' By making the art-generation function an x402 call, the user becomes a co-creator through micro-transactions, turning the exhibition into a high-velocity revenue stream for generative artists. Market: TAM $2.1B — The global digital art market, increasingly shifting toward algorithmic and programmable assets. | SAM $140M — The projected market for generative art and interactive digital installations. | SOM $8.5M — Niche focus on high-frequency, algorithmically-driven live events and 'pay-to-mutate' digital signage. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PRISM" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-frame curation. A headless gallery where every algorithmic seed generation requires a micro-payment. Collectors don't buy tickets; they pay to 'evolve' the art in real-time. Each 0.01 USDC call triggers a new parameter shift in the exhibition's global shader, settling instantly on Hedera. The gallery state is a collective stream of paid mutations. Discipline: Visual Art (algorithmic art exhibition). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves from passive 'ticketing' to active 'metered participation.' By making the art-generation function an x402 call, the user becomes a co-creator through micro-transactions, turning the exhibition into a high-velocity revenue stream for generative artists. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PRISM" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-artswap-network-23-x402 Title: CanvasLink · x402 Theme: Visual Art (visual-art) · visual art trades Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity drafting and discovery platform where every swipe, palette import, and high-res source reveal is a 0.01 USDC micro-settlement. Pro artists trade WIP access and proprietary brush settings behind an x402-metered paywall, ensuring that even 'browsing' provides instant liquidity to the creator. No subscriptions; you pay for the specific stroke or reference you want to swap or borrow. Why Hedera: By moving from 'free swaps' to x402-metered discovery, the app solves the 'leech' problem in digital art. Every interaction carries value, turning the act of scouting for a trade into a revenue stream for the artist. HTS transfer permits seamless, gasless-feeling micro-transfers that bypass traditional payment friction. Market: TAM $4.8B — The global digital art tools and assets market shifting toward fractional ownership and agentic commerce. | SAM $140M — Professional digital illustrators and concept artists utilizing onchain provenance. | SOM $2.8M — Active crypto-native artists on Hedera seeking granular monetization of their creative process. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CanvasLink" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity drafting and discovery platform where every swipe, palette import, and high-res source reveal is a 0.01 USDC micro-settlement. Pro artists trade WIP access and proprietary brush settings behind an x402-metered paywall, ensuring that even 'browsing' provides instant liquidity to the creator. No subscriptions; you pay for the specific stroke or reference you want to swap or borrow. Discipline: Visual Art (visual art trades). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'free swaps' to x402-metered discovery, the app solves the 'leech' problem in digital art. Every interaction carries value, turning the act of scouting for a trade into a revenue stream for the artist. HTS transfer permits seamless, gasless-feeling micro-transfers that bypass traditional payment friction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CanvasLink" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-canvas-chronicles-0-x402 Title: Provenance · x402 Theme: Visual Art (visual-art) · painting portfolios Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A programmable provenance layer for physical art. Collectors pay 0.05 USDC to verify a painting's high-res authenticity history or 'dip' into a painter’s process reel. Artists meter access to private viewing rooms and high-fidelity source files, turning the traditional portfolio into a per-view economy. Each view returns a Hedera transaction id, cryptographically linking the interest to the asset. Why Hedera: Shifts the portfolio from a static resume to a metered discovery engine. By charging micro-fees for verifiable history, it filters for high-intent collectors and provides immediate micropayment liquidity to artists for their digital 'footprint' without requiring a full NFT sale. Market: TAM $67B — The global fine art market moving toward transparent, metered digital authentication. | SAM $450M — The digital art market and fine art insurance/verification sector transitioning to on-chain tracking. | SOM $12M — Independent contemporary painters using Base for verifiable digital provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Provenance" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A programmable provenance layer for physical art. Collectors pay 0.05 USDC to verify a painting's high-res authenticity history or 'dip' into a painter’s process reel. Artists meter access to private viewing rooms and high-fidelity source files, turning the traditional portfolio into a per-view economy. Each view returns a Hedera transaction id, cryptographically linking the interest to the asset. Discipline: Visual Art (painting portfolios). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the portfolio from a static resume to a metered discovery engine. By charging micro-fees for verifiable history, it filters for high-intent collectors and provides immediate micropayment liquidity to artists for their digital 'footprint' without requiring a full NFT sale. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Provenance" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-sketch-stamp-1-x402 Title: INKPRINT · x402 Theme: Visual Art (visual-art) · illustration drafts Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographic proof-of-work layer for illustrators. Use Sketch to commit a draft hash to the ledger, creating an immutable, timestamped 'creative trail' that prevents AI scrapers or copycats from claiming your process as theirs. Every version saved is a permanent receipt of human authorship. Why Hedera: Micropayments turn version control into a legal defense strategy. By charging 0.01 USDC per 'stamp,' artists can afford to document every incremental change, building a high-resolution audit trail that is too expensive to fake but cheap enough to maintain during a 20-sketch session. Market: TAM $2.4B — The global digital illustration and creative software market moving toward automated IP watermarking. | SAM $180M — Independent digital illustrators and concept artists using blockchain for IP protection. | SOM $12M — Early adopters in the crypto-art and NFT space requiring verifiable 'Proof of Process' for high-value sales. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "INKPRINT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographic proof-of-work layer for illustrators. Use Sketch to commit a draft hash to the ledger, creating an immutable, timestamped 'creative trail' that prevents AI scrapers or copycats from claiming your process as theirs. Every version saved is a permanent receipt of human authorship. Discipline: Visual Art (illustration drafts). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Micropayments turn version control into a legal defense strategy. By charging 0.01 USDC per 'stamp,' artists can afford to document every incremental change, building a high-resolution audit trail that is too expensive to fake but cheap enough to maintain during a 20-sketch session. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "INKPRINT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-generative-genesis-2-x402 Title: ITERATE · x402 Theme: Visual Art (visual-art) · generative art iterations Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-seed-variation for generative explorers. Instead of paying for a final output, users stream 0.01 USDC per iteration to the artist's wallet to compute and reveal the next seed in the latent space. Each payment triggers an on-chain event that logs the specific parameter set, allowing users to 'purchase the evolution' of a piece frame-by-frame. Payment is the literal shutter button for the generative engine. Why Hedera: Traditional minting is too heavy for iterative exploration. x402 enables a 'metered discovery' model where the user pays for the compute and the artist's algorithm in real-time. It transforms the viewing experience into a transactional dialogue between the collector and the code. Market: TAM $2.1B — The global generative AI and algorithmic art market transition to micro-monetized iterations. | SAM $120M — Digital art collectors and generative enthusiasts participating in curated drops (Art Blocks, Highlight). | SOM $4.5M — High-frequency generative art tinkerers and AI-prompt engineers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ITERATE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-seed-variation for generative explorers. Instead of paying for a final output, users stream 0.01 USDC per iteration to the artist's wallet to compute and reveal the next seed in the latent space. Each payment triggers an on-chain event that logs the specific parameter set, allowing users to 'purchase the evolution' of a piece frame-by-frame. Payment is the literal shutter button for the generative engine. Discipline: Visual Art (generative art iterations). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional minting is too heavy for iterative exploration. x402 enables a 'metered discovery' model where the user pays for the compute and the artist's algorithm in real-time. It transforms the viewing experience into a transactional dialogue between the collector and the code. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ITERATE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-gallery-gatekeeper-3-x402 Title: Provenance Pulse · x402 Theme: Visual Art (visual-art) · gallery asset curation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Provenance is a stream, not a static record. Use x402 to monetize the verification trail: collectors and insurers pay $0.01 per HTS transfer signed request to verify high-res authenticity data, physical NFC-tag pings, or exhibition history. Each gallery scan or provenance check triggers an instant micro-settlement, turning asset security into a high-velocity revenue stream for curators. Why Hedera: Traditional provenance is a hurdle; x402 turns it into a metered service. By using pay-per-use authentication, galleries can monetize the 'look but don't touch' digital twin interactions, making security checks a profitable micro-transaction rather than an administrative cost. Market: TAM $67B — The global fine art market shifting toward digital-physical integration and verifiable asset tracking. | SAM $1.2B — Art tech and digital certification market adopting blockchain-based verification. | SOM $45M — Niche high-end galleries and boutique logistics firms requiring real-time, pay-per-call authenticity auditing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Provenance Pulse" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Provenance is a stream, not a static record. Use x402 to monetize the verification trail: collectors and insurers pay $0.01 per HTS transfer signed request to verify high-res authenticity data, physical NFC-tag pings, or exhibition history. Each gallery scan or provenance check triggers an instant micro-settlement, turning asset security into a high-velocity revenue stream for curators. Discipline: Visual Art (gallery asset curation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional provenance is a hurdle; x402 turns it into a metered service. By using pay-per-use authentication, galleries can monetize the 'look but don't touch' digital twin interactions, making security checks a profitable micro-transaction rather than an administrative cost. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Provenance Pulse" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-color-ledger-4-x402 Title: Chromatix · x402 Theme: Visual Art (visual-art) · color palette preservation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity color palette registry where designers and AI prompt engineers pay a 0.01 USDC micropayment to 'sample' or 'clone' professional-grade hexadecimal schemes directly into their workspace. Payment facilitates a sovereign audit trail of color usage, ensuring creators are compensated every time their aesthetic logic is referenced by another agent or human. Why Hedera: By turning the act of 'copying a hex code' into an on-chain event, the app creates a factual lineage for color trends. x402 allows for high-velocity, low-friction settlement that wouldn't be feasible with traditional minting fees, making the palette an active, metered asset rather than a static NFT. Market: TAM $2.1B — The global digital brand identity and color consultancy market. | SAM $450M — The digital design assets and stock color market for UI/UX/Web3 creators. | SOM $12M — On-chain generative artists and AI model trainers requiring authenticated color training data. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chromatix" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity color palette registry where designers and AI prompt engineers pay a 0.01 USDC micropayment to 'sample' or 'clone' professional-grade hexadecimal schemes directly into their workspace. Payment facilitates a sovereign audit trail of color usage, ensuring creators are compensated every time their aesthetic logic is referenced by another agent or human. Discipline: Visual Art (color palette preservation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the act of 'copying a hex code' into an on-chain event, the app creates a factual lineage for color trends. x402 allows for high-velocity, low-friction settlement that wouldn't be feasible with traditional minting fees, making the palette an active, metered asset rather than a static NFT. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chromatix" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-brushstroke-chain-5-x402 Title: Sable · x402 Theme: Visual Art (visual-art) · brush technique archives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity motion archive for digital painters. Access frame-by-frame pressure, tilt, and velocity data of legendary brush techniques. Every 'stroke-replay' is metered via x402, allowing students to practice along in real-time while creators earn 0.01 USDC per technique fetch. No subscriptions—just pay for the specific muscle memory you need to master. Why Hedera: Traditional brush packs are static files. By turning techniques into an on-demand stream of metadata, we enable a modular learning economy where masters are compensated for the exact frequency their 'signature' is referenced or practiced. Market: TAM $3.4B — The global online art education and digital creator economy. | SAM $450M — The digital illustration and concept art asset market, including brush packs and tutorials. | SOM $12M — On-chain digital art students and studio professionals utilizing Base for real-time asset licensing. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sable" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity motion archive for digital painters. Access frame-by-frame pressure, tilt, and velocity data of legendary brush techniques. Every 'stroke-replay' is metered via x402, allowing students to practice along in real-time while creators earn 0.01 USDC per technique fetch. No subscriptions—just pay for the specific muscle memory you need to master. Discipline: Visual Art (brush technique archives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional brush packs are static files. By turning techniques into an on-demand stream of metadata, we enable a modular learning economy where masters are compensated for the exact frequency their 'signature' is referenced or practiced. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sable" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-minted-murals-6-x402 Title: STREET SEAL · x402 Theme: Visual Art (visual-art) · public mural documentation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity street art archive where every high-resolution 'capture' or 'view' of a mural triggers a 0.01 USDC micropayment directly to the artist's wallet. Users sign a permission to unlock geographic location data and professional-grade documentation of fleeting public works, ensuring the creator gets paid for the preservation of their physical labor long after the wall is buffed. Free to browse thumbnails; 0.01 USDC to unlock the permanent digital twin. Why Hedera: Public art is historically difficult to monetize once the work is finished. By metering access to high-res documentation and provenance data via x402, we turn passersby into micro-patrons and incentivize the archiving of ephemeral street art through a pay-per-view model that bypasses traditional gallery gatekeepers. Market: TAM $2.1B — The global public art and cultural tourism economy, increasingly driven by digital documentation and social sharing. | SAM $120M — The digital art licensing and NFT collectibles market. | SOM $8.5M — Target users in major street-art hubs (NYC, Berlin, Miami) paying for exclusive provenance metadata and high-res unlocks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "STREET SEAL" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity street art archive where every high-resolution 'capture' or 'view' of a mural triggers a 0.01 USDC micropayment directly to the artist's wallet. Users sign a permission to unlock geographic location data and professional-grade documentation of fleeting public works, ensuring the creator gets paid for the preservation of their physical labor long after the wall is buffed. Free to browse thumbnails; 0.01 USDC to unlock the permanent digital twin. Discipline: Visual Art (public mural documentation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Public art is historically difficult to monetize once the work is finished. By metering access to high-res documentation and provenance data via x402, we turn passersby into micro-patrons and incentivize the archiving of ephemeral street art through a pay-per-view model that bypasses traditional gallery gatekeepers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "STREET SEAL" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-framechain-7-x402 Title: AuraGate · x402 Theme: Visual Art (visual-art) · art reproduction control Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity rendering layer for digital art where every pixel-perfect view is metered. Instead of one-time mints, users pay $0.01 per high-res render or 'Save to Device' action. Creators receive instant streaming royalty splits for every second their art is displayed on digital canvases or integrated into UI backgrounds. Ownership isn't just a static entry; it's a license to view that auto-settles via HTS transfer. Why Hedera: Shifts art value from speculative flipping to active utility. Users pay for the 'experience' of the reproduction, mirroring streaming models for visual media. x402 handles the high-volume, low-value transactions that would be gas-prohibitive for standard NFTs. Market: TAM $65B — The global art market, transitioning to digital-first reproduction and licensing. | SAM $850M — The digital signage and NFT display hardware market (Samsung/LG/Meural users). | SOM $12M — High-end digital art collectors and galleries on Hedera requiring proof-of-view tech. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "AuraGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity rendering layer for digital art where every pixel-perfect view is metered. Instead of one-time mints, users pay $0.01 per high-res render or 'Save to Device' action. Creators receive instant streaming royalty splits for every second their art is displayed on digital canvases or integrated into UI backgrounds. Ownership isn't just a static entry; it's a license to view that auto-settles via HTS transfer. Discipline: Visual Art (art reproduction control). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts art value from speculative flipping to active utility. Users pay for the 'experience' of the reproduction, mirroring streaming models for visual media. x402 handles the high-volume, low-value transactions that would be gas-prohibitive for standard NFTs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "AuraGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-illustrator-s-imprint-8-x402 Title: Imprint · x402 Theme: Visual Art (visual-art) · signed digital prints Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity digital gallery where every 'Right-Click Save' is a paid micro-transaction. Unlock a cryptographically signed, full-resolution master file for 0.01 USDC. Payment triggers an HTS transfer transfer that instantly settles a royalty to the illustrator, returning a Base transaction hash as your permanent proof of patronage. No subscriptions, just a 1-cent toll for high-art provenance. Why Hedera: Moving away from the 'minting' friction, this reframes digital art as a metered asset. By utilizing x402, we turn the act of viewing or downloading high-quality assets into a seamless micro-payment event, eliminating the need for complex NFT marketplaces for simple attribution and ownership. Market: TAM $45B — The global digital art and collectibles market, increasingly automated by AI agent collectors and programmatic curation. | SAM $850M — The shift toward 'direct-to-fan' micro-monetization for freelance digital illustrators and concept artists. | SOM $12M — Early adopters on Hedera seeking low-friction ways to support creators without the overhead of gas or high minting fees. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Imprint" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity digital gallery where every 'Right-Click Save' is a paid micro-transaction. Unlock a cryptographically signed, full-resolution master file for 0.01 USDC. Payment triggers an HTS transfer transfer that instantly settles a royalty to the illustrator, returning a Base transaction hash as your permanent proof of patronage. No subscriptions, just a 1-cent toll for high-art provenance. Discipline: Visual Art (signed digital prints). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving away from the 'minting' friction, this reframes digital art as a metered asset. By utilizing x402, we turn the act of viewing or downloading high-quality assets into a seamless micro-payment event, eliminating the need for complex NFT marketplaces for simple attribution and ownership. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Imprint" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-provenance-palette-9-x402 Title: Chromatix · x402 Theme: Visual Art (visual-art) · artist palette provenance Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol where every stroke is metered. Artists gate access to their digital pigment recipes and process-replays via x402. Fans pay 0.01 USDC to unlock the 'Digital Residue'—the exact hex-codes, mixing ratios, and stroke metadata used during a session—bridging the gap between the physical palette and on-chain aesthetic DNA. Why Hedera: Transitions from a static NFT minting platform to a live, paid-access data stream. By turning the 'process' into a metered asset, artists monetize the education and curiosity surrounding their unique color theory without high-friction minting costs. Market: TAM $2.4B — The global art collectibles and masterclass market transitioning to sub-cent digital access. | SAM $120M — Pro-creators and digital illustrators selling process-access to collectors. | SOM $450K — High-end fine artists on Hedera using x402 to gate 'Behind the Canvas' metadata. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chromatix" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol where every stroke is metered. Artists gate access to their digital pigment recipes and process-replays via x402. Fans pay 0.01 USDC to unlock the 'Digital Residue'—the exact hex-codes, mixing ratios, and stroke metadata used during a session—bridging the gap between the physical palette and on-chain aesthetic DNA. Discipline: Visual Art (artist palette provenance). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitions from a static NFT minting platform to a live, paid-access data stream. By turning the 'process' into a metered asset, artists monetize the education and curiosity surrounding their unique color theory without high-friction minting costs. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chromatix" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-pixel-provenance-10-x402 Title: DOTCHECK · x402 Theme: Visual Art (visual-art) · pixel art authentication Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-verification registry for high-fidelity pixel art. Instead of gas-heavy minting, creators pay 0.01 USDC to timestamp a hash of their canvas, and collectors pay 0.01 USDC to instantly query the provenance API and verify authenticity. Payment acts as the cryptographic handshake between artist and ledger. Why Hedera: By shifting from 'NFT Minting' to 'Pixel-Level Verification Calls,' we remove the friction of high gas fees and replace it with extreme-granularity micropayments. Every check of the art's history is a micro-transaction, turning provenance into a metered service for digital galleries and marketplaces. Market: TAM $3.8B — The global digital art and collectibles market transitioning to verifiable on-chain assets. | SAM $450M — The digital art authentication and licensing niche. | SOM $12M — Independent pixel artists and 'indie game' asset marketplaces requiring instant, low-cost verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DOTCHECK" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-verification registry for high-fidelity pixel art. Instead of gas-heavy minting, creators pay 0.01 USDC to timestamp a hash of their canvas, and collectors pay 0.01 USDC to instantly query the provenance API and verify authenticity. Payment acts as the cryptographic handshake between artist and ledger. Discipline: Visual Art (pixel art authentication). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'NFT Minting' to 'Pixel-Level Verification Calls,' we remove the friction of high gas fees and replace it with extreme-granularity micropayments. Every check of the art's history is a micro-transaction, turning provenance into a metered service for digital galleries and marketplaces. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DOTCHECK" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-exhibit-echo-11-x402 Title: EchoState · x402 Theme: Visual Art (visual-art) · virtual exhibition records Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless digital preservation engine. Collectors pay $0.01 USDC to 'mint' a high-fidelity spatial snapshot of a virtual gallery to their permanent record. Every time a visitor accesses the historical exhibit data, the original creator receives a real-time micropayment. Payment is the archival trigger; no signed transaction, no permanent record. Why Hedera: By shifting from static NFT minting to a pay-per-access archival model, creators earn recurring revenue from the longevity of their work rather than a one-time sale, while users get friction-free interaction via HTS transfer. Market: TAM $850M — The global digital art preservation and virtual tourism sector. | SAM $90M — The market for professional digital curators and virtual gallery operators. | SOM $4.5M — Niche creators using real-time spatial web tools on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "EchoState" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless digital preservation engine. Collectors pay $0.01 USDC to 'mint' a high-fidelity spatial snapshot of a virtual gallery to their permanent record. Every time a visitor accesses the historical exhibit data, the original creator receives a real-time micropayment. Payment is the archival trigger; no signed transaction, no permanent record. Discipline: Visual Art (virtual exhibition records). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from static NFT minting to a pay-per-access archival model, creators earn recurring revenue from the longevity of their work rather than a one-time sale, while users get friction-free interaction via HTS transfer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "EchoState" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-storyboard-stamp-12-x402 Title: FrameTrace · x402 Theme: Visual Art (visual-art) · concept art progression Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A non-custodial version control system for visual narratives. Pay 0.01 USDC to hash and checkpoint your storyboard progress on-chain. Each payment creates an immutable link between frames, proving creative lineage from rough sketch to final keyframe. Creators gate access to high-res evolution logs, charging fans per 'behind-the-scenes' frame reveal. Pay-per-stroke provenance for professional concept artists. Why Hedera: By moving from 'NFT minting' to 'per-frame checkpointing,' we transform a heavy, speculative action into a low-friction utility. Artists pay per save, and collectors pay per reveal, creating a liquid stream of micro-income during the production process rather than waiting for a final sale. Market: TAM $4.2B — Global digital content creation and visual storytelling market transitioning to micro-authenticated workflows. | SAM $850M — The digital art and pre-production software market adopting high-frequency provenance. | SOM $12M — Conceptual storyboarders and indie film production teams on-boarding to Base for creative audit trails. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FrameTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A non-custodial version control system for visual narratives. Pay 0.01 USDC to hash and checkpoint your storyboard progress on-chain. Each payment creates an immutable link between frames, proving creative lineage from rough sketch to final keyframe. Creators gate access to high-res evolution logs, charging fans per 'behind-the-scenes' frame reveal. Pay-per-stroke provenance for professional concept artists. Discipline: Visual Art (concept art progression). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'NFT minting' to 'per-frame checkpointing,' we transform a heavy, speculative action into a low-friction utility. Artists pay per save, and collectors pay per reveal, creating a liquid stream of micro-income during the production process rather than waiting for a final sale. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FrameTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-masterstroke-mint-13-x402 Title: Masterstroke · x402 Theme: Visual Art (visual-art) · signature artwork certs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Instantly verify or generate a cryptographically bound Certificate of Authenticity (CoA) for any physical or digital artwork. Each validation call anchors the signature provenance to Base. Collectors pay 0.01 USDC per scan to check the ledger; artists pay 0.01 USDC per asset to commit the master-hash. It eliminates the 'NFT minting' friction, replacing it with a high-velocity, pay-per-signature ledger for galleries and luxury appraisers. Why Hedera: By moving away from expensive, bulk NFT minting fees and focusing on pay-per-use metadata validation, 'Provenance' becomes a utility rather than a speculative asset. This allows for 'streaming' authenticity checks in high-traffic art marketplaces. Market: TAM $14B — The global art authentication and provenance tracking market. | SAM $850M — The digital art certification and appraisal technology sector. | SOM $12M — On-chain verification for high-end boutique galleries and digital-native creators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Masterstroke" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Instantly verify or generate a cryptographically bound Certificate of Authenticity (CoA) for any physical or digital artwork. Each validation call anchors the signature provenance to Base. Collectors pay 0.01 USDC per scan to check the ledger; artists pay 0.01 USDC per asset to commit the master-hash. It eliminates the 'NFT minting' friction, replacing it with a high-velocity, pay-per-signature ledger for galleries and luxury appraisers. Discipline: Visual Art (signature artwork certs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving away from expensive, bulk NFT minting fees and focusing on pay-per-use metadata validation, 'Provenance' becomes a utility rather than a speculative asset. This allows for 'streaming' authenticity checks in high-traffic art marketplaces. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Masterstroke" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-layer-ledger-14-x402 Title: Layer Ledger · x402 Theme: Visual Art (visual-art) · digital painting layers Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A professional digital canvas where every brushstroke or 'Merge Layer' action is a micro-settled provenance event. Instead of a single final mint, creators pay 0.01 USDC to cryptographically seal and timestamp individual process layers. This builds an immutable 'Proof of Craft' ledger that viewers or collectors pay to unlock, exposing the raw human technique behind AI-saturated art. Why Hedera: Moves digital art from a 'finished product' commodity to a 'process-as-a-service' model. x402 handles the high-frequency, low-value writes (sealing layers) and reads (viewing history) that would be economically impossible on-chain without micropayment primitives. Market: TAM $4.2B — The global creative software and digital collectible market shifting toward granular version control and provenance. | SAM $850M — Addressing the 'Proof of Human' segment of the digital art market and the burgeoning layer-based NFT derivative market. | SOM $12M — Target focus on high-fidelity digital illustrators on Hedera using Procreate/Photoshop who require process verification to command premium prices. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Layer Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A professional digital canvas where every brushstroke or 'Merge Layer' action is a micro-settled provenance event. Instead of a single final mint, creators pay 0.01 USDC to cryptographically seal and timestamp individual process layers. This builds an immutable 'Proof of Craft' ledger that viewers or collectors pay to unlock, exposing the raw human technique behind AI-saturated art. Discipline: Visual Art (digital painting layers). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves digital art from a 'finished product' commodity to a 'process-as-a-service' model. x402 handles the high-frequency, low-value writes (sealing layers) and reads (viewing history) that would be economically impossible on-chain without micropayment primitives. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Layer Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-artchain-auction-15-x402 Title: Vouch · x402 Theme: Visual Art (visual-art) · secondary art sales Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Provenance as a Protocol. Every bid, appraisal, and historical query is an x402-metered event. Collectors pay 0.01 USDC to unlock an asset's full cryptographic audit trail or to place a verified bid, ensuring that only high-intent liquidity interacts with the secondary market. No free riding on valuation data; every lookup fuels the creator's royalty pool instantly. Why Hedera: By turning 'provenance lookups' into a micropayment primitive, we eliminate bots and casual crawlers while creating a high-velocity revenue stream for original artists. It shifts art sales from bulky commissions to granular data-valuation. Market: TAM $65B — The global art market's annual turnover, increasingly shifting toward digital-first verification and fractional ownership. | SAM $1.4B — The projected volume of luxury goods and art moving through transparent, on-chain secondary marketplaces. | SOM $22M — High-frequency digital art traders and Base-native collectors utilizing granular provenance verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vouch" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Provenance as a Protocol. Every bid, appraisal, and historical query is an x402-metered event. Collectors pay 0.01 USDC to unlock an asset's full cryptographic audit trail or to place a verified bid, ensuring that only high-intent liquidity interacts with the secondary market. No free riding on valuation data; every lookup fuels the creator's royalty pool instantly. Discipline: Visual Art (secondary art sales). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'provenance lookups' into a micropayment primitive, we eliminate bots and casual crawlers while creating a high-velocity revenue stream for original artists. It shifts art sales from bulky commissions to granular data-valuation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vouch" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-color-code-vault-16-x402 Title: ChromaLock · x402 Theme: Visual Art (visual-art) · digital color codes Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity color lookup and protection engine. Pay 0.01 USDC to unlock the precise hex/CMYK specifications of proprietary palettes or sign a 'Proof of Origin' for a unique hex code to the Base ledger. Professional graders and brand designers meter their assets, ensuring every palette extraction is a billable event. Why Hedera: Turns static color data into a metered API. By utilizing x402, designers can gate their 'secret sauce' brand colors behind micropayments rather than selling entire libraries, while AI agents can programmatically fetch 'on-trend' palettes via signed auth. Market: TAM $4.2B — The global digital design and specialized software market transitioning to granular, per-use licensing. | SAM $850M — Revenue from professional brand consultants and UI/UX design agencies migrating to on-chain asset management. | SOM $12M — Early adoption by generative art platforms and crypto-native branding boutiques on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ChromaLock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity color lookup and protection engine. Pay 0.01 USDC to unlock the precise hex/CMYK specifications of proprietary palettes or sign a 'Proof of Origin' for a unique hex code to the Base ledger. Professional graders and brand designers meter their assets, ensuring every palette extraction is a billable event. Discipline: Visual Art (digital color codes). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Turns static color data into a metered API. By utilizing x402, designers can gate their 'secret sauce' brand colors behind micropayments rather than selling entire libraries, while AI agents can programmatically fetch 'on-trend' palettes via signed auth. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ChromaLock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-art-mentor-mark-17-x402 Title: Pedigree · x402 Theme: Visual Art (visual-art) · art mentorship proof Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Lineage as an automated ledger. Using x402, artists charge a micro-fee for every 'Influence Signature' granted to a student. Each payment triggers a cryptographically signed proof of mentorship that the student displays in their digital portfolio. Influence is no longer a vague claim; it is a metered, verifiable bond between master and apprentice settled on-chain. Why Hedera: Traditional mentorship is opaque and rarely compensated. By making the 'stamp of approval' a pay-per-use transaction, we create a quantifiable influence graph where mentors earn passive USDC for their reputation and students get immutable proof of study. Market: TAM $1.8B — The global online art education and credentialing market, transitioning to decentralized proof-of-skill. | SAM $275M — Digital art platforms and portfolio sites (Behance, ArtStation) and the freelance creator economy. | SOM $12M — Emerging digital painters and concept artists on Hedera seeking verifiable pedigree for studio hiring. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Pedigree" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Lineage as an automated ledger. Using x402, artists charge a micro-fee for every 'Influence Signature' granted to a student. Each payment triggers a cryptographically signed proof of mentorship that the student displays in their digital portfolio. Influence is no longer a vague claim; it is a metered, verifiable bond between master and apprentice settled on-chain. Discipline: Visual Art (art mentorship proof). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional mentorship is opaque and rarely compensated. By making the 'stamp of approval' a pay-per-use transaction, we create a quantifiable influence graph where mentors earn passive USDC for their reputation and students get immutable proof of study. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Pedigree" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-signature-seal-18-x402 Title: AuthSign · x402 Theme: Visual Art (visual-art) · digital signature minting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An invisible cryptographic handshake. Use x402 to programmatically seal digital assets with a verifiable biometric or stylus-based signature. Instead of heavy minting fees, creators pay 0.01 USDC per 'Seal' to generate a tamper-proof, time-stamped HTS transfer attestation. Collectors pay a micropayment to verify the provenance on-chain, turning every view into a micro-revenue event for the artist. Why Hedera: By commoditizing the 'signature' as a 1-cent utility rather than a high-cost NFT event, we shift from 'selling art' to 'metered authenticity' where every verification call generates revenue. Market: TAM $2.1B — Global digital art authentication and anti-forgery infrastructure. | SAM $450M — The on-chain provenance and digital forensics market for independent creators. | SOM $12M — Professional digital illustrators and concept artists on Hedera requiring low-friction proof-of-work. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "AuthSign" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An invisible cryptographic handshake. Use x402 to programmatically seal digital assets with a verifiable biometric or stylus-based signature. Instead of heavy minting fees, creators pay 0.01 USDC per 'Seal' to generate a tamper-proof, time-stamped HTS transfer attestation. Collectors pay a micropayment to verify the provenance on-chain, turning every view into a micro-revenue event for the artist. Discipline: Visual Art (digital signature minting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By commoditizing the 'signature' as a 1-cent utility rather than a high-cost NFT event, we shift from 'selling art' to 'metered authenticity' where every verification call generates revenue. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "AuthSign" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-nft-mirror-19-x402 Title: Specular · x402 Theme: Visual Art (visual-art) · artistic reflection sets Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A dual-view creative workspace where the canvas only reveals the 'Reflection' (an AI-interpreted or inverted counter-piece) upon a 0.01 USDC event. Artists stream strokes to a primary layer for free, but viewers and collectors trigger x402 calls to render the symmetrical shadow-work, creating a pay-per-view experience of creative duality. Each paid unlock generates a unique Hedera transaction id, cryptographically linking the original insight to its reflected twin. Why Hedera: By gating the 'mirror' rather than the 'mint,' the payment becomes a ritual of revelation. It moves from a static NFT pair to a dynamic, metered viewing experience where the artist is paid for every instance of duality revealed. Market: TAM $2.1B — The global digital art and collectible market transitioning to fractionalized, metered consumption. | SAM $120M — The emerging 'Pay-to-Reveal' digital gallery and modular art market. | SOM $4.5M — Base-native generative artists and reflection-based interactive art collectors. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Specular" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A dual-view creative workspace where the canvas only reveals the 'Reflection' (an AI-interpreted or inverted counter-piece) upon a 0.01 USDC event. Artists stream strokes to a primary layer for free, but viewers and collectors trigger x402 calls to render the symmetrical shadow-work, creating a pay-per-view experience of creative duality. Each paid unlock generates a unique Hedera transaction id, cryptographically linking the original insight to its reflected twin. Discipline: Visual Art (artistic reflection sets). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By gating the 'mirror' rather than the 'mint,' the payment becomes a ritual of revelation. It moves from a static NFT pair to a dynamic, metered viewing experience where the artist is paid for every instance of duality revealed. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Specular" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-art-rewind-20-x402 Title: LayerTrace · x402 Theme: Visual Art (visual-art) · creative process playback Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-frame provenance for digital strokes. Art Rewind allows creators to gate the playback of their creative process: pay 0.01 USDC to unlock the next 'layer' of the time-lapse or the full high-fidelity replay. Every stroke is cryptographically linked to a Base tx, turning the 'how it was made' into a metered educational and collectible asset. Purchases settle instantly, streaming revenue to the artist per view. Why Hedera: By moving from lumpy NFT mints to granular playback micropayments, the artist monetizes curiosity. Fans pay for the 'aha!' moment in the process rather than a static finished piece. x402 handles the high-frequency/low-value transactions required for frame-by-frame or step-by-step unboxing of complex digital art. Market: TAM $8.5B — Global digital art and online creator education economy. | SAM $450M — Revenue potential from the digital art tutorial and process-sharing market. | SOM $12M — Targeted capture of professional Procreate/Photoshop concept artists selling 'process packs' on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LayerTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-frame provenance for digital strokes. Art Rewind allows creators to gate the playback of their creative process: pay 0.01 USDC to unlock the next 'layer' of the time-lapse or the full high-fidelity replay. Every stroke is cryptographically linked to a Base tx, turning the 'how it was made' into a metered educational and collectible asset. Purchases settle instantly, streaming revenue to the artist per view. Discipline: Visual Art (creative process playback). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from lumpy NFT mints to granular playback micropayments, the artist monetizes curiosity. Fans pay for the 'aha!' moment in the process rather than a static finished piece. x402 handles the high-frequency/low-value transactions required for frame-by-frame or step-by-step unboxing of complex digital art. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LayerTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-gallery-provenance-21-x402 Title: Vouch · x402 Theme: Visual Art (visual-art) · physical gallery tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular audit log for physical spaces. Galleries meter each artwork 'ping' or 'scan' via x402, allowing collectors to pay 0.01 USDC to unlock an asset's full provenance, verified status, and curator notes on-demand. Move from static NFTs to living, metered histories where every verification event is a micro-settlement. Why Hedera: Instead of a one-time NFT mint, provenance becomes a dynamic service. Collectors, insurers, and shippers pay per inquiry to verify authenticity and location history. This turns the physical gallery into an API-accessible database where data 'reads' are monetized. Market: TAM $67B — The global art market moving towards transparent, automated chain-of-custody. | SAM $450M — Onchain provenance for the global high-end digital/physical hybrid art market. | SOM $12M — Micro-verification fees for emerging digital artists and boutique physical galleries on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vouch" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular audit log for physical spaces. Galleries meter each artwork 'ping' or 'scan' via x402, allowing collectors to pay 0.01 USDC to unlock an asset's full provenance, verified status, and curator notes on-demand. Move from static NFTs to living, metered histories where every verification event is a micro-settlement. Discipline: Visual Art (physical gallery tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Instead of a one-time NFT mint, provenance becomes a dynamic service. Collectors, insurers, and shippers pay per inquiry to verify authenticity and location history. This turns the physical gallery into an API-accessible database where data 'reads' are monetized. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vouch" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-nft-colorgram-22-x402 Title: Chromatix · x402 Theme: Visual Art (visual-art) · chromatic art NFTs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular digital palette where color is equity. Users pay $0.01 USDC to 'claim' a specific Hex/RGB coordinate, minting a transient chromatic asset. Every time another creator uses your protected color code in a generative canvas or spatial environment, a micro-royalty is settled on-chain. It transforms color theory into a liquid, metered economy. Why Hedera: By commoditizing the individual pixel/color coordinate via x402, we shift from selling 'finished' art to selling the atomic components of art. It creates a 'Proof of Pigment' protocol where every interaction with a specific hue is a metered event. Market: TAM $1.8B — The global digital art and design software market moving toward automated provenance. | SAM $240M — The emerging market for generative art assets and smart-contract-based licensing. | SOM $12M — Micro-licensing for independent digital artists and UI/UX designers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Chromatix" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular digital palette where color is equity. Users pay $0.01 USDC to 'claim' a specific Hex/RGB coordinate, minting a transient chromatic asset. Every time another creator uses your protected color code in a generative canvas or spatial environment, a micro-royalty is settled on-chain. It transforms color theory into a liquid, metered economy. Discipline: Visual Art (chromatic art NFTs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By commoditizing the individual pixel/color coordinate via x402, we shift from selling 'finished' art to selling the atomic components of art. It creates a 'Proof of Pigment' protocol where every interaction with a specific hue is a metered event. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Chromatix" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-motion-mint-23-x402 Title: Kinetic · x402 Theme: Visual Art (visual-art) · animated illustration ownership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Stream high-fidelity animated brushstrokes in real-time. Instead of traditional NFT minting, users pay a sub-cent fee to 'scrub' the timeline of an illustration or unlock a high-res loop for 24 hours. The creator receives instantaneous USDC settlement for every frame-data request, turning passive viewing into a metered interactive experience for collectors and wallpaper engines. Why Hedera: Shifts ownership from a static 'buy once' model to a 'pay-per-view-quality' model, ideal for digital signage and mobile backgrounds where users want variety without high-cost commitments. Market: TAM $2.8B — The global animation and digital illustration market transition to micro-licensed creative assets. | SAM $140M — The digital art collectibles market and high-end wallpaper subscription niche. | SOM $4.2M — Base-native digital art enthusiasts and developers of AI-driven ambient displays paying per asset-call. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Kinetic" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Stream high-fidelity animated brushstrokes in real-time. Instead of traditional NFT minting, users pay a sub-cent fee to 'scrub' the timeline of an illustration or unlock a high-res loop for 24 hours. The creator receives instantaneous USDC settlement for every frame-data request, turning passive viewing into a metered interactive experience for collectors and wallpaper engines. Discipline: Visual Art (animated illustration ownership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts ownership from a static 'buy once' model to a 'pay-per-view-quality' model, ideal for digital signage and mobile backgrounds where users want variety without high-cost commitments. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Kinetic" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA visual-art-provenance-palette-24-x402 Title: Pigment Archive · x402 Theme: Visual Art (visual-art) · physical palette digitization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity pipeline for digitizing physical artist palettes and extracting their exact hex-code DNA. Artists can gate their unique 'color secrets' or historic mixes behind x402 micropayments. Collectors or fellow painters pay 0.01 USDC to unlock a high-res digital scan and CMYK/Hex translation of a specific palette profile. Each use generates a Hedera transaction id, establishing an immutable provenance trail for the artist's specific color theory and material choice. Why Hedera: Traditional NFTs are high-friction for simple color extraction. By using x402, we turn the 'palette' into an active, metered resource. It transforms the physical byproduct of painting into a digital asset that earns via 'per-view' or 'per-sampling' fees. Market: TAM $3.2B — The global art supplies and digital asset management industry. | SAM $450M — The digital art tools market and professional color-library subscribers. | SOM $12M — On-chain artists and professional illustrators using Base seeking authentic material references. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Pigment Archive" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity pipeline for digitizing physical artist palettes and extracting their exact hex-code DNA. Artists can gate their unique 'color secrets' or historic mixes behind x402 micropayments. Collectors or fellow painters pay 0.01 USDC to unlock a high-res digital scan and CMYK/Hex translation of a specific palette profile. Each use generates a Hedera transaction id, establishing an immutable provenance trail for the artist's specific color theory and material choice. Discipline: Visual Art (physical palette digitization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFTs are high-friction for simple color extraction. By using x402, we turn the 'palette' into an active, metered resource. It transforms the physical byproduct of painting into a digital asset that earns via 'per-view' or 'per-sampling' fees. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Pigment Archive" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ============================================================================== THEME · Writing, Poetry & Narrative writers, poets, screenwriters, narrative designers ============================================================================== ------------------------------------------------------------------------------ IDEA writing-verseledger-0-x402 Title: VerseLedger · x402 Theme: Writing, Poetry & Narrative (writing) · poetry archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular attribution layer for poets. Instead of a one-time mint, VerseLedger meters the "Proof of Originality." Pay 0.01 USDC to cryptographically timestamp a stanza or signature, or 0.01 USDC to query the chain for a similarity check against the ledger. It turns the archive from a static list into a pay-per-verification utility for publishers and literary scouts. Why Hedera: Shift from 'storage' to 'verification service.' By pricing the timestamping and the plagiarism-check at the call level, the ledger becomes an active agent in the creative workflow rather than a passive database. Market: TAM $1.4B — The global academic and creative publishing industry shifting toward decentralized provenance. | SAM $95M — The growing market for digital intellectual property protection and plagiarism detection software. | SOM $4.2M — Independent poets and literary journals seeking low-friction, per-poem attribution on-chain. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VerseLedger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular attribution layer for poets. Instead of a one-time mint, VerseLedger meters the "Proof of Originality." Pay 0.01 USDC to cryptographically timestamp a stanza or signature, or 0.01 USDC to query the chain for a similarity check against the ledger. It turns the archive from a static list into a pay-per-verification utility for publishers and literary scouts. Discipline: Writing, Poetry & Narrative (poetry archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shift from 'storage' to 'verification service.' By pricing the timestamping and the plagiarism-check at the call level, the ledger becomes an active agent in the creative workflow rather than a passive database. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VerseLedger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrativevote-1-x402 Title: PlotLine · x402 Theme: Writing, Poetry & Narrative (writing) · interactive storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time collaborative logic engine for fiction where every narrative branch is a pay-per-vote micro-transaction. Authors publish 'Fractured Chapters' where the plot path is gated by liquidity; the story only progresses when the collective pool for a specific outcome hits its threshold. 0.01 USDC per vote ensures skin in the game while preventing bot-spamming of plot arcs. Readers don't just consume; they fund the creative direction one sentence at a time. Why Hedera: Traditional voting is easily gamed and provides no revenue for the writer. By using x402, every decision point becomes a revenue event. This turns readers into stakeholders and creates a direct financial incentive for writers to produce engaging cliffhangers. Market: TAM $18B — The global digital publishing and interactive media market. | SAM $420M — Web3 fiction platforms and decentralized publishing protocols. | SOM $15M — Interactive 'Choose Your Own Adventure' niche and serialized fiction newsletters (Substack/Wattpad power users). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PlotLine" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time collaborative logic engine for fiction where every narrative branch is a pay-per-vote micro-transaction. Authors publish 'Fractured Chapters' where the plot path is gated by liquidity; the story only progresses when the collective pool for a specific outcome hits its threshold. 0.01 USDC per vote ensures skin in the game while preventing bot-spamming of plot arcs. Readers don't just consume; they fund the creative direction one sentence at a time. Discipline: Writing, Poetry & Narrative (interactive storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional voting is easily gamed and provides no revenue for the writer. By using x402, every decision point becomes a revenue event. This turns readers into stakeholders and creates a direct financial incentive for writers to produce engaging cliffhangers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PlotLine" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-scriptroyalty-2-x402 Title: DraftFlow · x402 Theme: Writing, Poetry & Narrative (writing) · screenwriting rights Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular narrative licensing engine where screenwriters monetize at the scene level. Producers and AI pre-viz tools pay 0.01 USDC to 'Read & Reference' specific plot points or dialogue blocks. Each interaction triggers an HTS transfer transfer directly to the script's contributors, turning a static PDF into a metered IP asset. Payment is the permission layer for adaptation. Why Hedera: Traditional screenwriting residuals are opaque and delayed. By atomizing the script into x402-gated calls, writers capture value at the point of consumption (coverage, casting, or AI training) rather than waiting for a theatrical release that may never come. Market: TAM $14.2B — The global entertainment IP licensing and royalty management sector. | SAM $850M — The market for script coverage, developmental editing, and independent production legal clearances. | SOM $12M — Early-stage indie writers and 'Black List' style platforms integrating automated micro-clearance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DraftFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular narrative licensing engine where screenwriters monetize at the scene level. Producers and AI pre-viz tools pay 0.01 USDC to 'Read & Reference' specific plot points or dialogue blocks. Each interaction triggers an HTS transfer transfer directly to the script's contributors, turning a static PDF into a metered IP asset. Payment is the permission layer for adaptation. Discipline: Writing, Poetry & Narrative (screenwriting rights). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional screenwriting residuals are opaque and delayed. By atomizing the script into x402-gated calls, writers capture value at the point of consumption (coverage, casting, or AI training) rather than waiting for a theatrical release that may never come. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DraftFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-poetchain-3-x402 Title: VersePay · x402 Theme: Writing, Poetry & Narrative (writing) · collaborative poetry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: PoetChain turns collaborative stanzas into a metered economy. Every line added to a group poem costs 0.01 USDC, instantly distributed to the previous contributors. Use USDC to 'buy the rhyme' or 'break the meter,' creating a high-stakes exquisite corpse where every word is a micro-investment in the final narrative. x402 handles the atomic settlement between co-authors, turning creative flow into a verifiable revenue stream. Why Hedera: By pricing the 'add-a-line' action, poetry shifts from a static document to a competitive, incentivized game. The x402 wrapper ensures that editors and contributors are compensated for their influence on the piece, enabling a 'pay-to-play' creative loop that filters for quality. Market: TAM $2.1B — The global aggregate for self-publishing and collaborative content creation platforms. | SAM $140M — The digital creative writing and online workshop marketplace. | SOM $850K — High-frequency collaborative sessions among crypto-native poetry collectives and DAO writers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VersePay" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT PoetChain turns collaborative stanzas into a metered economy. Every line added to a group poem costs 0.01 USDC, instantly distributed to the previous contributors. Use USDC to 'buy the rhyme' or 'break the meter,' creating a high-stakes exquisite corpse where every word is a micro-investment in the final narrative. x402 handles the atomic settlement between co-authors, turning creative flow into a verifiable revenue stream. Discipline: Writing, Poetry & Narrative (collaborative poetry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By pricing the 'add-a-line' action, poetry shifts from a static document to a competitive, incentivized game. The x402 wrapper ensures that editors and contributors are compensated for their influence on the piece, enabling a 'pay-to-play' creative loop that filters for quality. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VersePay" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-plotproof-4-x402 Title: PlotProof · x402 Theme: Writing, Poetry & Narrative (writing) · story validation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Secure your IP at the speed of thought. PlotProof enables authors to notarize narrative beats, character arcs, and full drafts directly to Base. Every 'Proof' event anchors your creative timeline with a cryptographic timestamp, creating an immutable trail of originality that protects against plagiarism while providing a verifiable history of your story's evolution. Pay only when you protect. Why Hedera: By turning narrative validation into a pay-per-use primitive, we replace expensive legal registrations with 0.01 USDC micro-notarizations. This allows writers to 'save' their progress on-chain at every milestone, ensuring their IP is defendable from the first sentence. Market: TAM $1.2B — Global digital publishing and intellectual property management markets. | SAM $85M — Pro-sumer fiction writers and screenwriters seeking decentralized copyright tools. | SOM $4.2M — Web3 native authors and recursive storytellers on Farcaster and Paragraph. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PlotProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Secure your IP at the speed of thought. PlotProof enables authors to notarize narrative beats, character arcs, and full drafts directly to Base. Every 'Proof' event anchors your creative timeline with a cryptographic timestamp, creating an immutable trail of originality that protects against plagiarism while providing a verifiable history of your story's evolution. Pay only when you protect. Discipline: Writing, Poetry & Narrative (story validation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning narrative validation into a pay-per-use primitive, we replace expensive legal registrations with 0.01 USDC micro-notarizations. This allows writers to 'save' their progress on-chain at every milestone, ensuring their IP is defendable from the first sentence. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PlotProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-verseauction-5-x402 Title: Stanza · x402 Theme: Writing, Poetry & Narrative (writing) · poetry marketplace Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 per stanza. Verse is a high-throughput poetry engine where readers pay-per-line to reveal the next segment of a poem. Poets receive instant USDC settlement as the narrative unfolds, turning linear reading into a micro-transactional event. No subscriptions, just a metered stream of consciousness. Why Hedera: Traditional poem 'auctions' create high friction. By atomizing the poem into x402-metered stanzas, we monetize the reader's curiosity in real-time. This transforms poetry from a static asset into a streaming service where every 'Next' click is a revenue event for the writer. Market: TAM $2.8B — Global digital publishing and micro-fiction market. | SAM $420M — Digital literature and indie publishing platforms transitioning to metered consumption. | SOM $15M — Early-adopter poetry communities and 'AI vs Human' credentialed writing circles on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stanza" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 per stanza. Verse is a high-throughput poetry engine where readers pay-per-line to reveal the next segment of a poem. Poets receive instant USDC settlement as the narrative unfolds, turning linear reading into a micro-transactional event. No subscriptions, just a metered stream of consciousness. Discipline: Writing, Poetry & Narrative (poetry marketplace). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional poem 'auctions' create high friction. By atomizing the poem into x402-metered stanzas, we monetize the reader's curiosity in real-time. This transforms poetry from a static asset into a streaming service where every 'Next' click is a revenue event for the writer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stanza" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narratordao-6-x402 Title: Inkwell · x402 Theme: Writing, Poetry & Narrative (writing) · community storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cellular-automata writing engine where every word is a transaction. Pay 0.01 USDC to commit the next sentence to a persistent, forks-become-realities narrative tree. Contributors earn automated rebates when others build upon their specific branch, turning storytelling into a competitive, high-stakes economy of lore. Why Hedera: By replacing governance votes with a pay-per-line primitive, we eliminate the friction of traditional DAO 'proposals.' The transaction is the vote. This ensures skin-in-the-game and creates a self-funding treasury for the story's preservation. Market: TAM $2.8B — The global online publishing and fan-fiction market transitioning toward micro-monetized, decentralized IP ownership. | SAM $450M — The creative writing and digital fiction market, specifically targeting the Web3 'lore-crafting' and collaborative world-building niche. | SOM $12M — The immediate ecosystem of Base-native crypto-native writers and AI-storytelling agents looking for verifiable narrative provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Inkwell" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cellular-automata writing engine where every word is a transaction. Pay 0.01 USDC to commit the next sentence to a persistent, forks-become-realities narrative tree. Contributors earn automated rebates when others build upon their specific branch, turning storytelling into a competitive, high-stakes economy of lore. Discipline: Writing, Poetry & Narrative (community storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing governance votes with a pay-per-line primitive, we eliminate the friction of traditional DAO 'proposals.' The transaction is the vote. This ensures skin-in-the-game and creates a self-funding treasury for the story's preservation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Inkwell" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-scenemint-7-x402 Title: SceneLock · x402 Theme: Writing, Poetry & Narrative (writing) · script segments Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A scriptwriter’s assembly line where every scene-pull is a micro-settlement. Instead of selling a whole script, SceneLock lets directors, storyboard artists, or AI video generators access specific, high-fidelity script segments for 0.01 USDC. Payment triggers the instant HTS transfer transfer, returning a Hedera transaction id that serves as the cryptographically verifiable 'Right to Render' for that specific narrative beat. Why Hedera: Traditional licensing is too slow for the rapid-prototyping era. x402 allows for 'fluid scripts' where narrative components are treated as priced metadata, enabling a generative media pipeline where agents pay per prompt-segment. Market: TAM $45B — The global film and media production market transitioning to modular, AI-assisted workflows. | SAM $1.2B — High-frequency narrative licensing for indie creators, storyboard apps, and AI video prompt-engineering. | SOM $18M — Early-stage film production houses and decentralized writers' rooms using Base for script versioning. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SceneLock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A scriptwriter’s assembly line where every scene-pull is a micro-settlement. Instead of selling a whole script, SceneLock lets directors, storyboard artists, or AI video generators access specific, high-fidelity script segments for 0.01 USDC. Payment triggers the instant HTS transfer transfer, returning a Hedera transaction id that serves as the cryptographically verifiable 'Right to Render' for that specific narrative beat. Discipline: Writing, Poetry & Narrative (script segments). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional licensing is too slow for the rapid-prototyping era. x402 allows for 'fluid scripts' where narrative components are treated as priced metadata, enabling a generative media pipeline where agents pay per prompt-segment. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SceneLock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-metapoem-8-x402 Title: METASTROFE · x402 Theme: Writing, Poetry & Narrative (writing) · dynamic poetry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Transmute the verse. METASTROFE is a living manuscript where each stanza is locked behind a micro-transaction. Paying the toll doesn't just grant access; it triggers a smart contract mutation that evolves the vocabulary and tone of the poem for the next reader based on the previous payer's wallet activity. A collaborative, permanent descent into a collective narrative where reading is an act of creation. Why Hedera: By shifting from 'events' to 'paid evolution,' every reading becomes a creative stake. x402 facilitates the high-velocity micro-contributions needed to keep a poem 'dynamic' without the friction of traditional transaction confirmations. Market: TAM $2.8B — The global niche for digital poetry, experimental literature, and the growing 'Owner-Economies' for independent creators. | SAM $140M — The digital collectibles and generative art market on Hedera, targeting users who buy 'Open Editions' and interactive NFTs. | SOM $1.2M — Narrative-driven micro-communities and 'Onchain Summer' participants who value permanent, evolving digital metadata. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "METASTROFE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Transmute the verse. METASTROFE is a living manuscript where each stanza is locked behind a micro-transaction. Paying the toll doesn't just grant access; it triggers a smart contract mutation that evolves the vocabulary and tone of the poem for the next reader based on the previous payer's wallet activity. A collaborative, permanent descent into a collective narrative where reading is an act of creation. Discipline: Writing, Poetry & Narrative (dynamic poetry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'events' to 'paid evolution,' every reading becomes a creative stake. x402 facilitates the high-velocity micro-contributions needed to keep a poem 'dynamic' without the friction of traditional transaction confirmations. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "METASTROFE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-authorshipchain-9-x402 Title: NOM-DE-GUERRE · x402 Theme: Writing, Poetry & Narrative (writing) · writer identity Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographically secure pen-name manager where every interaction—claiming a pseudonym, verifying a portfolio, or signing a manuscript—is a discrete 0.01 USDC transaction. By turning authorship into a metered on-chain primitive, writers build liquid reputations across platforms while preventing 'identity squatting' through micro-costs. Sign once with the embedded wallet, pay per proof. Why Hedera: Traditional identity registries fail because they are either free (spam-heavy) or expensive (high gas). x402 introduces a 'friction-tax' that validates intent. Payment is the proof of stake for the identity. Market: TAM $2.8B — The global digital identity and rights management market for independent creators. | SAM $420M — Web3 publishing platforms, decentralized social media protocols (Lens/Farcaster), and AI-content verification markets. | SOM $12M — Professional ghostwriters and pseudonymous crypto-native researchers requiring verifiable track records. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "NOM-DE-GUERRE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographically secure pen-name manager where every interaction—claiming a pseudonym, verifying a portfolio, or signing a manuscript—is a discrete 0.01 USDC transaction. By turning authorship into a metered on-chain primitive, writers build liquid reputations across platforms while preventing 'identity squatting' through micro-costs. Sign once with the embedded wallet, pay per proof. Discipline: Writing, Poetry & Narrative (writer identity). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional identity registries fail because they are either free (spam-heavy) or expensive (high gas). x402 introduces a 'friction-tax' that validates intent. Payment is the proof of stake for the identity. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "NOM-DE-GUERRE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-plottoken-10-x402 Title: PlotProof · x402 Theme: Writing, Poetry & Narrative (writing) · story licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized narrative vault where every plot twist is metered. Writers lock high-concept premises behind x402 gates; producers pay 0.01 USDC to 'peek' at the full synopsis or 1.00 USDC to secure a 24-hour option. Each interaction generates a Hedera transaction id, providing an immutable paper trail of chain-of-title before the script is even written. Paid-per-read becomes the new standard for IP discovery. Why Hedera: By turning 'reading' into a micro-transaction, we eliminate the friction of traditional NDAs and create a high-velocity marketplace for intellectual property. The facilitator acts as a notary for creative intent. Market: TAM $120B — The global entertainment licensing and content acquisition market across film, streaming, and publishing. | SAM $1.5B — Professional screenwriters, indie authors, and literary agents utilizing digital rights management platforms. | SOM $45M — Web3-native creators and experimental storytellers on Hedera seeking provable attribution and instant micro-monetization. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PlotProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized narrative vault where every plot twist is metered. Writers lock high-concept premises behind x402 gates; producers pay 0.01 USDC to 'peek' at the full synopsis or 1.00 USDC to secure a 24-hour option. Each interaction generates a Hedera transaction id, providing an immutable paper trail of chain-of-title before the script is even written. Paid-per-read becomes the new standard for IP discovery. Discipline: Writing, Poetry & Narrative (story licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'reading' into a micro-transaction, we eliminate the friction of traditional NDAs and create a high-velocity marketplace for intellectual property. The facilitator acts as a notary for creative intent. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PlotProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-scriptchainaudit-11-x402 Title: BeatStamp · x402 Theme: Writing, Poetry & Narrative (writing) · version tracking Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A version-controlled text editor where every 'Save' or 'Branch' is a pay-per-commit micro-transaction. Instead of a monthly SaaS subscription, writers pay 0.01 USDC to notarize a specific narrative beat, dialogue tweak, or scene revision. This creates a high-fidelity, cost-enforced audit trail for IP protection. Producers or legal teams can then pay to 'Unlock Audit' to verify the chronological evolution of a script during chain-of-title disputes. Why Hedera: Traditional version control for writers is either free (untrustworthy for legal) or expensive (heavy legal fees). x402 turns every save into a legally-defensible timestamp, shifting the cost from a flat fee to a metered, usage-based IP protection layer. Market: TAM $3.2B — Global screenplay software market and IP litigation services. | SAM $450M — Independent screenwriters, ghostwriters, and collaborative playwrights requiring proof-of-work. | SOM $12M — Base-native creative DAO contributors and script-focused NFT writers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "BeatStamp" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A version-controlled text editor where every 'Save' or 'Branch' is a pay-per-commit micro-transaction. Instead of a monthly SaaS subscription, writers pay 0.01 USDC to notarize a specific narrative beat, dialogue tweak, or scene revision. This creates a high-fidelity, cost-enforced audit trail for IP protection. Producers or legal teams can then pay to 'Unlock Audit' to verify the chronological evolution of a script during chain-of-title disputes. Discipline: Writing, Poetry & Narrative (version tracking). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional version control for writers is either free (untrustworthy for legal) or expensive (heavy legal fees). x402 turns every save into a legally-defensible timestamp, shifting the cost from a flat fee to a metered, usage-based IP protection layer. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "BeatStamp" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-freeversedao-12-x402 Title: Stanza · x402 Theme: Writing, Poetry & Narrative (writing) · poetry funding Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-line. FreeVerse turns every poem into a metered stream of consciousness. Readers pay a single USDC cent to unlock the next stanza, with funds settling instantly to the poet's the embedded wallet-managed wallet. No subscriptions, no ads, just a direct value-transfer between word and reader. Poets can set 'milestone' lines that trigger larger facilitator-settled grant payouts once a specific reading volume is reached. Why Hedera: Poetry is often consumed in fragments; x402 allows for granular monetization of the reading experience itself. By moving from a heavy DAO governance model to a lightweight pay-per-line model, we create a high-velocity circular economy for literary funding. Market: TAM $1.2B — The global creative writing and digital self-publishing market, shifting toward micro-monetized AI-assisted narratives. | SAM $85M — The niche market for independent digital poetry, literary journals, and paid newsletter micro-tiers. | SOM $4.2M — Onchain literary enthusiasts and 'Base Summer' creators using embedded wallets for frictionless micro-tips. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stanza" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-line. FreeVerse turns every poem into a metered stream of consciousness. Readers pay a single USDC cent to unlock the next stanza, with funds settling instantly to the poet's the embedded wallet-managed wallet. No subscriptions, no ads, just a direct value-transfer between word and reader. Poets can set 'milestone' lines that trigger larger facilitator-settled grant payouts once a specific reading volume is reached. Discipline: Writing, Poetry & Narrative (poetry funding). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Poetry is often consumed in fragments; x402 allows for granular monetization of the reading experience itself. By moving from a heavy DAO governance model to a lightweight pay-per-line model, we create a high-velocity circular economy for literary funding. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stanza" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrativebadge-13-x402 Title: CanonInk · x402 Theme: Writing, Poetry & Narrative (writing) · writer credentials Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A meritocratic proof-of-skill primitive where writers pay 0.01 USDC to submit work for deep-chain validation. Each successful peer-review or automated narrative audit mints a tradeable, liquid credential. Payment isn't just a fee; it's the 'skin in the game' required to trigger the verification oracle and filter out low-effort noise in the creator economy. Why Hedera: By moving from free 'participation badges' to paid 'computationally verified credentials,' the asset gains immediate economic weight. Using x402 ensures that even high-frequency narrative micro-tasks (like line-editing or structure checks) can be credentialed without gas friction. Market: TAM $2.4B — The global professional certification market transitioning to decentralized, real-time micro-credentials. | SAM $450M — On-chain job boards, bounty hunters, and DAO contributors requiring verifiable skill-gating. | SOM $12M — Web3 game writers and narrative designers seeking Base-native proof of work for specific studio hires. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CanonInk" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A meritocratic proof-of-skill primitive where writers pay 0.01 USDC to submit work for deep-chain validation. Each successful peer-review or automated narrative audit mints a tradeable, liquid credential. Payment isn't just a fee; it's the 'skin in the game' required to trigger the verification oracle and filter out low-effort noise in the creator economy. Discipline: Writing, Poetry & Narrative (writer credentials). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from free 'participation badges' to paid 'computationally verified credentials,' the asset gains immediate economic weight. Using x402 ensures that even high-frequency narrative micro-tasks (like line-editing or structure checks) can be credentialed without gas friction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CanonInk" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-poemchainprint-14-x402 Title: Vellum · x402 Theme: Writing, Poetry & Narrative (writing) · limited edition poetry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Poets publish encrypted verses where every 'read' or 'print' event triggers a 0.01 USDC micropayment directly to the writer. Use x402 to meter access to the full text, turning each scroll into a revenue-generating event. Collectors pay to unlock the high-resolution 'Print' state, generating a Hedera transaction id that serves as the provable signature of the edition's scarcity. Why Hedera: By replacing traditional flat-fee NFTs with pay-per-read/pay-per-print utility, poets capture value from every interaction, not just the initial sale. It transforms poetry from a static digital object into a metered, streaming asset. Market: TAM $2.8B — The global limited edition art and rare book collector market transitioning to digital-first ownership. | SAM $420M — The digital collectibles and independent publishing market on Hedera/L2s. | SOM $15M — On-chain poets and generative literature enthusiasts using HashPack-integrated dApps. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vellum" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Poets publish encrypted verses where every 'read' or 'print' event triggers a 0.01 USDC micropayment directly to the writer. Use x402 to meter access to the full text, turning each scroll into a revenue-generating event. Collectors pay to unlock the high-resolution 'Print' state, generating a Hedera transaction id that serves as the provable signature of the edition's scarcity. Discipline: Writing, Poetry & Narrative (limited edition poetry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing traditional flat-fee NFTs with pay-per-read/pay-per-print utility, poets capture value from every interaction, not just the initial sale. It transforms poetry from a static digital object into a metered, streaming asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vellum" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrativestake-15-x402 Title: GhostWriter · x402 Theme: Writing, Poetry & Narrative (writing) · story contributor rewards Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Collaborative world-building where word-count is the currency. Authors pay 0.01 USDC to commit a story beat, branch a plotline, or kill a character. The accumulated pool is autonomously distributed to authors based on the number of 'upstream' reads and follow-on contributions their nodes trigger. Every sentence is a billable micro-asset. Why Hedera: x402 transforms narrative contribution from a vague reputation metric into a high-velocity revenue stream. By charging per 'commit,' it prevents spam while turning the story into a self-sustaining economy where the most influential writers are paid instantly by the readers and contributors who follow them. Market: TAM $400B — The global digital publishing and creator economy market, increasingly moving toward granular monetization. | SAM $1.2B — The growing market for interactive fiction, web-novels, and collaborative IP development platforms. | SOM $15M — Early adopters in the crypto-fiction space and DAO-based world-building collectives (e.g., Loot-style communities). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GhostWriter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Collaborative world-building where word-count is the currency. Authors pay 0.01 USDC to commit a story beat, branch a plotline, or kill a character. The accumulated pool is autonomously distributed to authors based on the number of 'upstream' reads and follow-on contributions their nodes trigger. Every sentence is a billable micro-asset. Discipline: Writing, Poetry & Narrative (story contributor rewards). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: x402 transforms narrative contribution from a vague reputation metric into a high-velocity revenue stream. By charging per 'commit,' it prevents spam while turning the story into a self-sustaining economy where the most influential writers are paid instantly by the readers and contributors who follow them. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GhostWriter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-scriptvote-16-x402 Title: Blacklist · x402 Theme: Writing, Poetry & Narrative (writing) · script feedback Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Scriptwriting is a solitary grind; professional coverage is overpriced. 'Blacklist' turns the screenplay into a metered asset. Authors lock scenes or specific script beats behind a $0.01 fee. Peer reviewers earn USDC for every granular critique submitted, while writers pay per vote to surface the data-driven 'heat map' of their narrative's pacing. It turns feedback from a favor into a high-velocity micro-market. Why Hedera: By shifting from 'voting' to 'metered critique,' the app eliminates feedback fatigue. The x402 model ensures that every note has a cost and every reviewer has a stake, preventing spam and incentivizing professional-grade brevity. Market: TAM $4.2B — The creator economy for long-form narrative, including web-novelists and playwrights. | SAM $850M — The global screenwriting software and freelance script coverage market. | SOM $12M — Independent screenwriters and film students using Base for decentralized portfolio management. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Blacklist" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Scriptwriting is a solitary grind; professional coverage is overpriced. 'Blacklist' turns the screenplay into a metered asset. Authors lock scenes or specific script beats behind a $0.01 fee. Peer reviewers earn USDC for every granular critique submitted, while writers pay per vote to surface the data-driven 'heat map' of their narrative's pacing. It turns feedback from a favor into a high-velocity micro-market. Discipline: Writing, Poetry & Narrative (script feedback). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'voting' to 'metered critique,' the app eliminates feedback fatigue. The x402 model ensures that every note has a cost and every reviewer has a stake, preventing spam and incentivizing professional-grade brevity. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Blacklist" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-poetryescrow-17-x402 Title: VerseFlow · x402 Theme: Writing, Poetry & Narrative (writing) · commissioned writing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-line narrative engine where stanza delivery is metered via USDC micropayments. Instead of high-friction escrow, writers stream poetic content stanza-by-stanza, triggered by 0.01 USDC auth sigs. Readers pay for 'The Next Verse', ensuring writers are paid for work produced in real-time and buyers only pay for the length they consume. Provides immediate settlement for ghostwriters and poets without multi-day escrow lockups. Why Hedera: Escrow is a high-latency legacy model. By shifting to x402, we turn the poem into a metered API. Payment becomes the primitive for 'releasing the next line', eliminating the need for trust or complex dispute resolution. If the buyer stops paying, the poet stops writing; if the poet stops writing, the buyer stops paying. Market: TAM $2.8B — The global gig economy for creative writing and personalized commissions. | SAM $450M — The digital freelance writing and micro-fiction market transitioning to real-time settlement. | SOM $12M — Web3-native poets and prompt-engineering narrative designers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VerseFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-line narrative engine where stanza delivery is metered via USDC micropayments. Instead of high-friction escrow, writers stream poetic content stanza-by-stanza, triggered by 0.01 USDC auth sigs. Readers pay for 'The Next Verse', ensuring writers are paid for work produced in real-time and buyers only pay for the length they consume. Provides immediate settlement for ghostwriters and poets without multi-day escrow lockups. Discipline: Writing, Poetry & Narrative (commissioned writing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Escrow is a high-latency legacy model. By shifting to x402, we turn the poem into a metered API. Payment becomes the primitive for 'releasing the next line', eliminating the need for trust or complex dispute resolution. If the buyer stops paying, the poet stops writing; if the poet stops writing, the buyer stops paying. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VerseFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-characternft-18-x402 Title: GHOSTWRITER · x402 Theme: Writing, Poetry & Narrative (writing) · narrative IP Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Transmit story-world DNA. A headless lore engine where every character's soul is a metered API. Instead of static minting, users pay a micropayment to 'manifest' a character into a new story, fork a personality trait, or authorize a spin-off scene. Writers earn USDC every time their IP is invoked by other creators or AI narrative agents, turning character development into a real-time revenue stream. Why Hedera: Traditional NFT licensing is too high-friction for rapid, collaborative storytelling. By shifting to x402, we enable 'micro-licensing'—where the cost to include a character in a single paragraph or chapter is negligible, but scales infinitely across the agentic web. Market: TAM $14B — Global character licensing and derivative IP markets, moving toward automated, programmatic royalty distribution. | SAM $450M — The emerging 'long-tail' fiction market, encompassing webnovel platforms, collaborative roleplay forums, and indie game assets. | SOM $12M — Base-native writers and AI-driven narrative labs utilizing automated character-interaction protocols. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GHOSTWRITER" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Transmit story-world DNA. A headless lore engine where every character's soul is a metered API. Instead of static minting, users pay a micropayment to 'manifest' a character into a new story, fork a personality trait, or authorize a spin-off scene. Writers earn USDC every time their IP is invoked by other creators or AI narrative agents, turning character development into a real-time revenue stream. Discipline: Writing, Poetry & Narrative (narrative IP). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional NFT licensing is too high-friction for rapid, collaborative storytelling. By shifting to x402, we enable 'micro-licensing'—where the cost to include a character in a single paragraph or chapter is negligible, but scales infinitely across the agentic web. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GHOSTWRITER" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-dialoguedao-19-x402 Title: Ghostwriter · x402 Theme: Writing, Poetry & Narrative (writing) · script collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-line collaborative script engine. Every dialogue submission or edit requires a $0.01 USDC micro-stake via x402. Revenue from the final script sale or streaming royalties is automatically streamed back to contributors based on their HTS transfer signature weight. No voting, just skin-in-the-game syntax. Why Hedera: Traditional DAOs suffer from 'governance fatigue' and high gas. By turning every line of dialogue into a $0.01 micro-transaction, you filter for quality and create a granular ledger of authorship. The payment is the proof of contribution. Market: TAM $2.8B — Global media production and creative collaborative software market. | SAM $450M — Scriptwriting software and digital collaborative writing tools for indie film/TV. | SOM $12M — Web3 native writers' rooms, AI-assisted script prototyping, and decentralized IP incubation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Ghostwriter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-line collaborative script engine. Every dialogue submission or edit requires a $0.01 USDC micro-stake via x402. Revenue from the final script sale or streaming royalties is automatically streamed back to contributors based on their HTS transfer signature weight. No voting, just skin-in-the-game syntax. Discipline: Writing, Poetry & Narrative (script collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional DAOs suffer from 'governance fatigue' and high gas. By turning every line of dialogue into a $0.01 micro-transaction, you filter for quality and create a granular ledger of authorship. The payment is the proof of contribution. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Ghostwriter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-poetproofs-20-x402 Title: InkLock · x402 Theme: Writing, Poetry & Narrative (writing) · draft notarization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency intellectual property ledger for writers. Instead of one-time legal fees, poets stream 0.01 USDC per draft version to secure an immutable Base ledger proof. Every 'Save' is a notarized checkpoint, creating a cryptographic paper trail that turns creative iteration into a bulletproof legal asset. Payment = Provenance. Why Hedera: Current copyright services are high-friction and expensive. By lowering the cost to a single cent via HTS transfer, we capture the 'shitty first draft' market, allowing writers to sign their work into existence as they think, rather than waiting for a finished product. Market: TAM $2.8B — The global intellectual property management and digital rights protection market. | SAM $420M — Professional and hobbyist writers using digital tools for version control and IP protection. | SOM $12M — Web3-native poets and digital journalists requiring instant, verifiable proof-of-authorship for fast-moving social media content. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkLock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency intellectual property ledger for writers. Instead of one-time legal fees, poets stream 0.01 USDC per draft version to secure an immutable Base ledger proof. Every 'Save' is a notarized checkpoint, creating a cryptographic paper trail that turns creative iteration into a bulletproof legal asset. Payment = Provenance. Discipline: Writing, Poetry & Narrative (draft notarization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Current copyright services are high-friction and expensive. By lowering the cost to a single cent via HTS transfer, we capture the 'shitty first draft' market, allowing writers to sign their work into existence as they think, rather than waiting for a finished product. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkLock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-plotchainswap-21-x402 Title: InkFlow · x402 Theme: Writing, Poetry & Narrative (writing) · story idea exchange Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency exchange for narrative architecture. Writers pay 0.01 USDC to pull a unique, AI-distilled plot hook or 'seed' from a collective treasury. Contributors earn micropayments every time their logic-tree or twist is accessed. It turns plot-block into a liquid market where inspiration is metered, not barely bartered. Why Hedera: Barter is slow; micropayments create instant liquidity for creative ideas. x402 allows for 'pay-per-prompt' or 'pay-per-twist' mechanics where the small cost (0.01 USDC) filters out low-quality noise while rewarding prolific world-builders. Market: TAM $4.2B (The global creative writing and scriptwriting software market). | SAM $580M (The digital self-publishing and ghostwriting market seeking rapid ideation tools). | SOM $12M (On-chain fiction writers and AI-assisted narrative designers using Base). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency exchange for narrative architecture. Writers pay 0.01 USDC to pull a unique, AI-distilled plot hook or 'seed' from a collective treasury. Contributors earn micropayments every time their logic-tree or twist is accessed. It turns plot-block into a liquid market where inspiration is metered, not barely bartered. Discipline: Writing, Poetry & Narrative (story idea exchange). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Barter is slow; micropayments create instant liquidity for creative ideas. x402 allows for 'pay-per-prompt' or 'pay-per-twist' mechanics where the small cost (0.01 USDC) filters out low-quality noise while rewarding prolific world-builders. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrativemint-22-x402 Title: LOREFLOW · x402 Theme: Writing, Poetry & Narrative (writing) · story tokenization Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Reveal the next plot point. NarrativeMint meters storytelling by the paragraph. Readers pay per beat to advance the chronologue, creating a real-time auction for narrative direction. Authors earn USDC for every 'Next' click, while high-value plot twists are gated behind premium x402 signatures, turning readers into active patrons of the flow. Why Hedera: Shifts the model from 'static ownership' (which has high friction) to 'streaming consumption' (which has high velocity). By charging per paragraph or plot-beat, it leverages the low-fee environment of Base to make reading a high-frequency payment activity. Market: TAM $2.1B — The global creative economy moving toward granular, pay-per-chapter monetization models. | SAM $450M — The digital serial fiction and web-novel market (Wattpad, Kindle Vella) shifting to micro-rebates. | SOM $12M — Independent fiction writers and 'choose-your-own-adventure' creators on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LOREFLOW" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Reveal the next plot point. NarrativeMint meters storytelling by the paragraph. Readers pay per beat to advance the chronologue, creating a real-time auction for narrative direction. Authors earn USDC for every 'Next' click, while high-value plot twists are gated behind premium x402 signatures, turning readers into active patrons of the flow. Discipline: Writing, Poetry & Narrative (story tokenization). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from 'static ownership' (which has high friction) to 'streaming consumption' (which has high velocity). By charging per paragraph or plot-beat, it leverages the low-fee environment of Base to make reading a high-frequency payment activity. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LOREFLOW" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-screenplaystake-23-x402 Title: ScriptScout · x402 Theme: Writing, Poetry & Narrative (writing) · crowd script reviewing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-read narrative oracle. Writers pay 0.05 USDC per page to secure a 'Proof of Review' from verified narrative scouts. Reviewers receive instant micropayments upon submitting a cryptographically signed critique. No staking lockups—just pure micro-metered feedback loops where every note is a discrete on-chain settlement. Why Hedera: Traditional staking creates friction for mobile users. By shifting to a pay-per-call (per page or per review) model, we enable high-velocity feedback where the cost of entry prevents spam and the immediate payout attracts elite script doctors. Market: TAM $2.8B — Global creative writing, script consultancy, and professional editing services. | SAM $420M — The independent screenplay and digital fiction market seeking professional coverage. | SOM $12M — Web3-native screenwriters and decentralized production houses (DAOs) using Base for script development. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScriptScout" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-read narrative oracle. Writers pay 0.05 USDC per page to secure a 'Proof of Review' from verified narrative scouts. Reviewers receive instant micropayments upon submitting a cryptographically signed critique. No staking lockups—just pure micro-metered feedback loops where every note is a discrete on-chain settlement. Discipline: Writing, Poetry & Narrative (crowd script reviewing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional staking creates friction for mobile users. By shifting to a pay-per-call (per page or per review) model, we enable high-velocity feedback where the cost of entry prevents spam and the immediate payout attracts elite script doctors. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScriptScout" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-poetrychaingift-24-x402 Title: VerseVault · x402 Theme: Writing, Poetry & Narrative (writing) · secure gifting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A locked-box narrative engine. Recipients pay a 0.01 USDC micro-transit fee to decrypt and 'unseal' poetry gifted to them. Every reading event triggers a provenance update, turning the act of gifting into a persistent, metered interaction where the sentiment is secured by the chain and the access is governed by the x402 protocol. Why Hedera: By shifting from a one-time 'purchase' to a pay-per-reveal model, we turn a static poem into a recurring ritual. The x402 layer ensures that the creator or gifter can meter the experience, ensuring only the intended signatory can ‘unlock’ the verse through a micro-transaction. Market: TAM $2.8B — The global poetry and artisanal digital gifting economy. | SAM $450M — The digital greeting card and personalization market moving to Web3. | SOM $12M — Onchain creators and gift-givers using Hedera testnet for low-friction sentiment transfer. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VerseVault" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A locked-box narrative engine. Recipients pay a 0.01 USDC micro-transit fee to decrypt and 'unseal' poetry gifted to them. Every reading event triggers a provenance update, turning the act of gifting into a persistent, metered interaction where the sentiment is secured by the chain and the access is governed by the x402 protocol. Discipline: Writing, Poetry & Narrative (secure gifting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a one-time 'purchase' to a pay-per-reveal model, we turn a static poem into a recurring ritual. The x402 layer ensures that the creator or gifter can meter the experience, ensuring only the intended signatory can ‘unlock’ the verse through a micro-transaction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VerseVault" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-poetic-memory-vault-0-x402 Title: DraftTrace · x402 Theme: Writing, Poetry & Narrative (writing) · poetry archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Archivists and scholars pay 0.01 USDC to unlock the 'ghost layer' of a poem—revealing the time-stamped revisions, deleted stanzas, and marginalia behind the final version. Every session-save by a poet is a micro-settlement on Hedera, turning the creative process into a permanent, verifiable asset. Pay to peer into the process; pay to preserve the legacy. Why Hedera: By commodifying the 'draft' rather than just the final product, we create a recurring revenue model for poets. The pay-per-view mechanism for revisions mimics the experience of visiting a physical literary archive, but at the scale of internet micropayments. Market: TAM $1.2B — Global poetry preservation, academic archives, and the digital collectibles market. | SAM $120M — Digital literary estates, MFA programs, and premium poetry subscribers. | SOM $4.5M — Niche collectors of high-intent contemporary poetry and academic researchers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DraftTrace" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Archivists and scholars pay 0.01 USDC to unlock the 'ghost layer' of a poem—revealing the time-stamped revisions, deleted stanzas, and marginalia behind the final version. Every session-save by a poet is a micro-settlement on Hedera, turning the creative process into a permanent, verifiable asset. Pay to peer into the process; pay to preserve the legacy. Discipline: Writing, Poetry & Narrative (poetry archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By commodifying the 'draft' rather than just the final product, we create a recurring revenue model for poets. The pay-per-view mechanism for revisions mimics the experience of visiting a physical literary archive, but at the scale of internet micropayments. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DraftTrace" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrative-flow-sync-1-x402 Title: PlotLine · x402 Theme: Writing, Poetry & Narrative (writing) · interactive storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-branch. A non-linear narrative engine where every reader choice triggers a micro-transaction to the author, instantly minting a unique story path to Base. Readers pay to fork storylines, writers earn per plot-twist explored. Accessing the global 'World Tree' of narrative branches requires an HTS transfer signature, ensuring creators are compensated for every creative divergence. Why Hedera: Narrative Flow Sync is reframed from a storage tool to a 'pay-per-path' economic engine. By making story branches cost 0.01 USDC, we turn readers into active stakeholders in the story's development and provide writers with a high-velocity, micro-revenue stream for long-tail creative work. Market: TAM $1.2B — The global interactive storytelling market, including gaming dialogue systems and branching e-books. | SAM $180M — The digital fiction and web-novel market, shifting toward micro-transactional 'pay-per-chapter' models. | SOM $12M — Web3-native writers and collaborative RPG communities utilizing Base for low-cost narrative state management. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PlotLine" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-branch. A non-linear narrative engine where every reader choice triggers a micro-transaction to the author, instantly minting a unique story path to Base. Readers pay to fork storylines, writers earn per plot-twist explored. Accessing the global 'World Tree' of narrative branches requires an HTS transfer signature, ensuring creators are compensated for every creative divergence. Discipline: Writing, Poetry & Narrative (interactive storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Narrative Flow Sync is reframed from a storage tool to a 'pay-per-path' economic engine. By making story branches cost 0.01 USDC, we turn readers into active stakeholders in the story's development and provide writers with a high-velocity, micro-revenue stream for long-tail creative work. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PlotLine" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-character-bios-hub-2-x402 Title: Persona Prime · x402 Theme: Writing, Poetry & Narrative (writing) · character development Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographically secure lore-vault for character-driven universes. Creators lock high-fidelity assets (backstories, secret traits, concept art) behind pay-per-view gates. Narrative designers and gaming studios pay 0.01 USDC to pull specific character metadata directly into their workflow, ensuring creators are compensated for every reference and world-building detail. Why Hedera: Shifts narrative work from a 'static wiki' to a 'metered API for lore,' allowing writers to monetize the underlying DNA of their intellectual property. Market: TAM $180B — Global character licensing and intellectual property market. | SAM $550M — Narrative designers and indie game developers licensing character IP. | SOM $12M — Web3 gaming studios and RPG creators on Hedera looking for modular story assets. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Persona Prime" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographically secure lore-vault for character-driven universes. Creators lock high-fidelity assets (backstories, secret traits, concept art) behind pay-per-view gates. Narrative designers and gaming studios pay 0.01 USDC to pull specific character metadata directly into their workflow, ensuring creators are compensated for every reference and world-building detail. Discipline: Writing, Poetry & Narrative (character development). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts narrative work from a 'static wiki' to a 'metered API for lore,' allowing writers to monetize the underlying DNA of their intellectual property. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Persona Prime" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-verse-visualizer-3-x402 Title: VerseLens · x402 Theme: Writing, Poetry & Narrative (writing) · poetical imagery Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn abstract metaphors into immutable digital artifacts. Pay 0.01 USDC to 'materialize' a poetic image, locking the verse-to-visual link on-chain. Every time another writer references or utilizes your metaphor for inspiration, a micropayment flows back to the original poet. It's a metered library of sensory data where every creative 'look' sustains the author. Why Hedera: Shifts poetry from static text to a metered creative asset. By charging 0.01 USDC per visualization/unlock, we create a high-velocity 'Imagery API' for writers, where payment validates the resonance of the metaphor. Market: TAM $850M — The global creative writing and digital asset inspiration market, moving toward granular, pay-per-use intellectual property. | SAM $45M — The niche of digital poets, prompt engineers, and creative writing software users seeking non-generative, human-curated imagery foundations. | SOM $1.2M — On-chain writers and Base ecosystem creators using micro-transactions for collaborative world-building. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VerseLens" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn abstract metaphors into immutable digital artifacts. Pay 0.01 USDC to 'materialize' a poetic image, locking the verse-to-visual link on-chain. Every time another writer references or utilizes your metaphor for inspiration, a micropayment flows back to the original poet. It's a metered library of sensory data where every creative 'look' sustains the author. Discipline: Writing, Poetry & Narrative (poetical imagery). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts poetry from static text to a metered creative asset. By charging 0.01 USDC per visualization/unlock, we create a high-velocity 'Imagery API' for writers, where payment validates the resonance of the metaphor. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VerseLens" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-screenplay-snapshot-4-x402 Title: FINAL TAKE · x402 Theme: Writing, Poetry & Narrative (writing) · screenwriting versioning Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Screenwriting is a war of attrition. Scene-level snapshots shouldn't be 'saved'—they should be 'stamped'. Sign a 0.01 USDC micropayment to immutably commit a scene variant to Base. Each 'Take' creates a verifiable, time-stamped hash of your narrative pivot, allowing writers to branch and prune their scripts with financial finality. No more 'Final_Final_v2.docx'; just a ledger of creative evolution. Why Hedera: By shifting versioning from a passive auto-save to a deliberate x402-metered 'Take,' the writer treats their script like a film production. The pay-per-commit model prevents digital clutter and creates a verifiable chain of custody for intellectual property on-chain. Market: TAM $4.2B — The total creator economy segment focused on narrative asset management and versioning. | SAM $850M — The global screenwriting and episodic content production market embracing decentralized IP protection. | SOM $12M — Independent screenwriters and playwrights using Base for version control and IP proof-of-existence. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FINAL TAKE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Screenwriting is a war of attrition. Scene-level snapshots shouldn't be 'saved'—they should be 'stamped'. Sign a 0.01 USDC micropayment to immutably commit a scene variant to Base. Each 'Take' creates a verifiable, time-stamped hash of your narrative pivot, allowing writers to branch and prune their scripts with financial finality. No more 'Final_Final_v2.docx'; just a ledger of creative evolution. Discipline: Writing, Poetry & Narrative (screenwriting versioning). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting versioning from a passive auto-save to a deliberate x402-metered 'Take,' the writer treats their script like a film production. The pay-per-commit model prevents digital clutter and creates a verifiable chain of custody for intellectual property on-chain. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FINAL TAKE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrative-map-archive-5-x402 Title: PLOTLINE · x402 Theme: Writing, Poetry & Narrative (writing) · story mapping Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A version-controlled repository for branching narratives where every 'leaf' node and story deviation requires a micropayment to commit or retrieve. Instead of broad subscriptions, narrative teams pay per branch generated by AI or queried by writers. The protocol ensures that world-builders are paid 0.01 USDC every time their specific lore-bits are referenced or integrated into a new scenario. It turns plot-mapping from static documentation into a live, metered asset library. Why Hedera: Story mapping is traditionally a sunk cost. By moving to x402, every narrative path becomes a distinct digital asset. The pay-per-use model prevents 'bloat' in massive open-world design and creates a financial incentive for granular, high-quality world-building. Market: TAM $1.2B — The global game narrative design and procedural content generation market. | SAM $85M — Narrative design tools for indie game studios and collaborative fiction DAO contributors. | SOM $4.2M — Narrative designers and writers building on-chain RPGs and interactive fiction on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PLOTLINE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A version-controlled repository for branching narratives where every 'leaf' node and story deviation requires a micropayment to commit or retrieve. Instead of broad subscriptions, narrative teams pay per branch generated by AI or queried by writers. The protocol ensures that world-builders are paid 0.01 USDC every time their specific lore-bits are referenced or integrated into a new scenario. It turns plot-mapping from static documentation into a live, metered asset library. Discipline: Writing, Poetry & Narrative (story mapping). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Story mapping is traditionally a sunk cost. By moving to x402, every narrative path becomes a distinct digital asset. The pay-per-use model prevents 'bloat' in massive open-world design and creates a financial incentive for granular, high-quality world-building. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PLOTLINE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-writer-s-prompt-vault-6-x402 Title: InkGate · x402 Theme: Writing, Poetry & Narrative (writing) · creative prompts Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A cryptographically secured prompt engine for professional authors and AI-hybrid writers. Every prompt is gated by a 0.01 USDC micro-settlement, ensuring that premium narrative seeds are exclusive, timestamped on Hedera, and monetized at the point of inspiration. Don't just browse prompts; unlock them and own the provenance of your story's origin. Why Hedera: By replacing free scrolling with x402-metered unlocks, we transform prompts from commodity noise into high-value creative assets. The pay-per-view model ensures curators are compensated for quality while preventing bulk scraping by LLMs without settlement. Market: TAM $2.8B — The global digital publishing and creative writing software market. | SAM $140M — Professional ghostwriters, screenwriters, and competitive storytellers. | SOM $1.2M — On-chain writers and decentralized fiction communities using Base and HashPack. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A cryptographically secured prompt engine for professional authors and AI-hybrid writers. Every prompt is gated by a 0.01 USDC micro-settlement, ensuring that premium narrative seeds are exclusive, timestamped on Hedera, and monetized at the point of inspiration. Don't just browse prompts; unlock them and own the provenance of your story's origin. Discipline: Writing, Poetry & Narrative (creative prompts). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By replacing free scrolling with x402-metered unlocks, we transform prompts from commodity noise into high-value creative assets. The pay-per-view model ensures curators are compensated for quality while preventing bulk scraping by LLMs without settlement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrative-token-ledger-7-x402 Title: LoreSeed · x402 Theme: Writing, Poetry & Narrative (writing) · story ownership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Own your lore one plot point at a time. Every story contribution, world-building detail, or character arc is registered via a 0.01 USDC micropayment, creating a cryptographically signed provenance trail. Pay to mint a narrative claim; pay to verify a peer's lore; pay to fork a story branch. It turns fan-fiction and collaborative world-building into a structured, pay-per-entry IP ledger where the creator of the original seed earns on every downstream derivative settle. Why Hedera: By atomizing story ownership to the 'beat' level, we enable a high-velocity narrative market. The x402 model replaces messy legal contracts with instant, micro-transactional IP registration, making it viable for digital-native writers and AI-collaborative storytelling. Market: TAM $25B — Global IP licensing and digital publishing rights market. | SAM $450M — The collaborative fiction and creator economy segment on Hedera. | SOM $12M — Early-stage 'lore-runners' and decentralized writers' rooms. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LoreSeed" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Own your lore one plot point at a time. Every story contribution, world-building detail, or character arc is registered via a 0.01 USDC micropayment, creating a cryptographically signed provenance trail. Pay to mint a narrative claim; pay to verify a peer's lore; pay to fork a story branch. It turns fan-fiction and collaborative world-building into a structured, pay-per-entry IP ledger where the creator of the original seed earns on every downstream derivative settle. Discipline: Writing, Poetry & Narrative (story ownership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By atomizing story ownership to the 'beat' level, we enable a high-velocity narrative market. The x402 model replaces messy legal contracts with instant, micro-transactional IP registration, making it viable for digital-native writers and AI-collaborative storytelling. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LoreSeed" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-poem-remix-repository-8-x402 Title: VerseBranch · x402 Theme: Writing, Poetry & Narrative (writing) · poetry collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Fork, fuse, and refine verses with surgical precision. Each line remix costs 0.01 USDC, instantly rewarding the previous author via HTS transfer. Payment acts as the transformative 'save' mechanism, turning passive reading into active, paid co-creation. Every stanza is a micro-transactional asset, building a living, branching narrative tree where the most remixed poems generate the highest yield for their originators. No subscriptions—just pay per pivot. Why Hedera: By turning poetry into a paid 'remix' primitive, we solve the attribution problem in collaborative writing. The 0.01 USDC fee acts as a quality filter and a direct royalty stream, incentivizing poets to post high-quality 'seeds' for others to branch. Market: TAM $4.5B — The global digital publishing and social writing market, pivoting toward micro-monetized fan fiction and collaborative IP. | SAM $180M — Web3-native writers and generative AI agents participating in collaborative 'prompt-poetry' ecosystems. | SOM $12M — High-velocity poetry slams and 'Exquisite Corpse' style game participants on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VerseBranch" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Fork, fuse, and refine verses with surgical precision. Each line remix costs 0.01 USDC, instantly rewarding the previous author via HTS transfer. Payment acts as the transformative 'save' mechanism, turning passive reading into active, paid co-creation. Every stanza is a micro-transactional asset, building a living, branching narrative tree where the most remixed poems generate the highest yield for their originators. No subscriptions—just pay per pivot. Discipline: Writing, Poetry & Narrative (poetry collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning poetry into a paid 'remix' primitive, we solve the attribution problem in collaborative writing. The 0.01 USDC fee acts as a quality filter and a direct royalty stream, incentivizing poets to post high-quality 'seeds' for others to branch. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VerseBranch" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-screenplay-beatboard-9-x402 Title: BeatLock · x402 Theme: Writing, Poetry & Narrative (writing) · plot structuring Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-beat narrative engine where every plot turn is a signed transaction. Commit a screenplay beat to the ledger to lock story logic, generate branching AI subplots, or verify creative provenance. Writers pay 0.01 USDC to 'harden' a scene version, creating an immutable, timestamped breadcrumb trail for co-writers or production legal rooms. No subscriptions—only pay for the depth of your world-building. Why Hedera: Screenwriting requires strict version control and legal 'first-to-file' proof of ideation. By making each beat an x402 event, the writer creates a verifiable audit trail of their intellectual property development, while micro-billing allows for high-frequency iteration without the burden of a monthly SaaS fee. Market: TAM $2.4B — The global entertainment software and pre-production market. | SAM $180M — Independent screenwriters, script doctors, and collaborative writers' rooms moving away from legacy subscription software. | SOM $12M — Web3-native creators and narrative designers building lore for on-chain games and episodic NFT content. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "BeatLock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-beat narrative engine where every plot turn is a signed transaction. Commit a screenplay beat to the ledger to lock story logic, generate branching AI subplots, or verify creative provenance. Writers pay 0.01 USDC to 'harden' a scene version, creating an immutable, timestamped breadcrumb trail for co-writers or production legal rooms. No subscriptions—only pay for the depth of your world-building. Discipline: Writing, Poetry & Narrative (plot structuring). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Screenwriting requires strict version control and legal 'first-to-file' proof of ideation. By making each beat an x402 event, the writer creates a verifiable audit trail of their intellectual property development, while micro-billing allows for high-frequency iteration without the burden of a monthly SaaS fee. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "BeatLock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrative-soundtrack-archive-10-x402 Title: Vocal Ledger · x402 Theme: Writing, Poetry & Narrative (writing) · audio storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized repository for narrative audio assets where every 'listen' or 'sync' is a micro-settlement. Authors lock atmospheric soundtracks and voice stems behind x402 gates, allowing creators to pay $0.01 per atmospheric layer used in their own productions. No subscriptions—just pay-per-sample provenance for high-fidelity storytelling. Why Hedera: By moving from a library model to a pay-per-use primitive, we turn listeners and remixers into instant micro-patrons. Using HTS transfer permits sub-cent friction for pulling a single sound effect or narrative beat without the overhead of a monthly SaaS fee. Market: TAM $2.4B — The global digital audio content and creator economy market. | SAM $450M — The creative 'elements' and stock audio market segments moving toward per-unit licensing. | SOM $8.5M — Independent podcasters, TTRPG creators, and narrative game devs on Hedera seeking modular, on-chain soundscapes. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Vocal Ledger" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized repository for narrative audio assets where every 'listen' or 'sync' is a micro-settlement. Authors lock atmospheric soundtracks and voice stems behind x402 gates, allowing creators to pay $0.01 per atmospheric layer used in their own productions. No subscriptions—just pay-per-sample provenance for high-fidelity storytelling. Discipline: Writing, Poetry & Narrative (audio storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from a library model to a pay-per-use primitive, we turn listeners and remixers into instant micro-patrons. Using HTS transfer permits sub-cent friction for pulling a single sound effect or narrative beat without the overhead of a monthly SaaS fee. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Vocal Ledger" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-metadata-story-weave-11-x402 Title: LoreGraph · x402 Theme: Writing, Poetry & Narrative (writing) · story metadata management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A narrative-preservation layer where authors anchor rich context—character bibles, world-building lore, and stylistic seeds—to the blockchain. Users pay 0.01 USDC to query the 'Deep Lore' of any story or unlock the metadata graph required for AI-generated sequels and fan-fiction branchings. Revenue flows instantly to the original creator on every metadata call. Why Hedera: By turning metadata from a passive file into a metered API, the story's 'DNA' becomes a liquid asset. This prevents lore fragmentation and ensures authors are paid for the foundational work that powers downstream derivatives and LLM-assisted expansions. Market: TAM $1.4B — The global digital publishing and interactive narrative market shifting toward provenance-backed metadata. | SAM $280M — The emerging economy of AI agents and automated researchers querying narrative ontologies for content generation. | SOM $12M — Independent serial fiction authors on Hedera using metered metadata to gate world-building bibles. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LoreGraph" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A narrative-preservation layer where authors anchor rich context—character bibles, world-building lore, and stylistic seeds—to the blockchain. Users pay 0.01 USDC to query the 'Deep Lore' of any story or unlock the metadata graph required for AI-generated sequels and fan-fiction branchings. Revenue flows instantly to the original creator on every metadata call. Discipline: Writing, Poetry & Narrative (story metadata management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning metadata from a passive file into a metered API, the story's 'DNA' becomes a liquid asset. This prevents lore fragmentation and ensures authors are paid for the foundational work that powers downstream derivatives and LLM-assisted expansions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LoreGraph" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-epic-poem-archive-12-x402 Title: Epos · x402 Theme: Writing, Poetry & Narrative (writing) · long-form poetry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized vault for long-form verse where every stanza is an unlockable event. Metered billing ensures poets are paid 0.01 USDC per Canto read or version-forked, turning the 'Epic' into a sustainable, streaming narrative asset. No subscriptions, just a micro-flow of value for every line consumed. Why Hedera: By shifting from an archive to a metered-access protocol, long-form poetry moves from 'dead file' to 'active stream.' x402 allows for granular monetization of massive texts (pay-per-page) which traditionally struggle with the 'all or nothing' Kindle/Substack models. Market: TAM $1.8B — The global creative writing and specialty publishing market refactored for granular agentic consumption. | SAM $450M — The digital literary and niche long-form publishing market moving toward direct-to-wallet micro-consumption. | SOM $12M — On-chain poets and decentralized narrative designers utilizing Base for low-cost, high-frequency text interactions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Epos" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized vault for long-form verse where every stanza is an unlockable event. Metered billing ensures poets are paid 0.01 USDC per Canto read or version-forked, turning the 'Epic' into a sustainable, streaming narrative asset. No subscriptions, just a micro-flow of value for every line consumed. Discipline: Writing, Poetry & Narrative (long-form poetry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from an archive to a metered-access protocol, long-form poetry moves from 'dead file' to 'active stream.' x402 allows for granular monetization of massive texts (pay-per-page) which traditionally struggle with the 'all or nothing' Kindle/Substack models. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Epos" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-flash-fiction-cache-13-x402 Title: Inkwell · x402 Theme: Writing, Poetry & Narrative (writing) · short story publishing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes prose marketplace where every word is a micro-transaction. Authors lock flash fiction behind a 0.01 USDC gate; readers pay-per-read via instant HTS transfer signing. No subscriptions, no ads, just pure cryptographic value exchange for one-minute stories. Every unlock settles instantly on Hedera, establishing a verifiable 'Proof of Read' and a direct revenue stream for the narrative economy. Why Hedera: By turning the 'permanent access' into a 'pay-per-access' model, we shift from a passive archive to an active narrative protocol. The friction of cents is negligible for the reader but transformative for the author's recurring revenue when automated by agents or micro-patrons. Market: TAM $2.8B — Global digital short-form publishing and narrative creator economy. | SAM $450M — The micro-fiction and newsletter-supplement market transitioning to pay-per-view. | SOM $12M — On-chain literary enthusiasts and Base-native users seeking low-friction content consumption. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Inkwell" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes prose marketplace where every word is a micro-transaction. Authors lock flash fiction behind a 0.01 USDC gate; readers pay-per-read via instant HTS transfer signing. No subscriptions, no ads, just pure cryptographic value exchange for one-minute stories. Every unlock settles instantly on Hedera, establishing a verifiable 'Proof of Read' and a direct revenue stream for the narrative economy. Discipline: Writing, Poetry & Narrative (short story publishing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the 'permanent access' into a 'pay-per-access' model, we shift from a passive archive to an active narrative protocol. The friction of cents is negligible for the reader but transformative for the author's recurring revenue when automated by agents or micro-patrons. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Inkwell" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-collaborative-verse-chain-14-x402 Title: Stanzaify · x402 Theme: Writing, Poetry & Narrative (writing) · co-written poetry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A collaborative poetry engine where every stanza requires a 0.01 USDC commit. Writers stake a micropayment to append a line, creating a financial-creative skin-in-the-game. When a poem is 'closed' or sold as an NFT, the x402 ledger automatically redistributes collected fees to contributors based on their line-count. Intellectual property enforced by the transaction hash. Why Hedera: By turning the 'post' action into a micro-transaction, you eliminate junk/spam contributions and create a self-funding treasury for each poem. The payment acts as the version control timestamp and the authorship proof simultaneously. Market: TAM $2.8B — The global poetry and creative writing market transitioning to digital-first, fractional ownership. | SAM $420M — The digital publishing and independent creator economy adopting micro-monetization. | SOM $12M — Web3 writers and collaborative DAOs on Hedera using automated revenue splits for short-form content. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stanzaify" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A collaborative poetry engine where every stanza requires a 0.01 USDC commit. Writers stake a micropayment to append a line, creating a financial-creative skin-in-the-game. When a poem is 'closed' or sold as an NFT, the x402 ledger automatically redistributes collected fees to contributors based on their line-count. Intellectual property enforced by the transaction hash. Discipline: Writing, Poetry & Narrative (co-written poetry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the 'post' action into a micro-transaction, you eliminate junk/spam contributions and create a self-funding treasury for each poem. The payment acts as the version control timestamp and the authorship proof simultaneously. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stanzaify" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrative-artifact-ledger-15-x402 Title: LORELOOT · x402 Theme: Writing, Poetry & Narrative (writing) · story props and lore Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-pull lore engine for immersive storytelling. Authors mint 'Encrypted Props' (hidden documents, voice logs, or plot items) that remain obscured until a reader settles a 0.01 USDC micro-transaction. Payment triggers a server-side reveal of the metadata and a Base settlement hash, turning narrative discovery into a tangible exchange of value. Perfect for ARGs, tabletop campaigns, and digital novels where truth has a price. Why Hedera: By placing story lore behind a sub-cent payment wall, writers can monetize the 'deep dive' aspects of their world-building that are usually forgotten in free wikis, while readers treat each reveal as a physical acquisition of a secret. Market: TAM $2.8B — The global digital collectibles and narrative media market, increasingly shifting toward micro-transactional access models. | SAM $420M — The growing 'creator economy' for indie TTRPG publishers and serialized web-fiction writers seeking per-chapter or per-item monetization. | SOM $15M — Early adopters in the ARG (Alternate Reality Game) and Discord-based roleplaying communities utilizing Base for instant asset unlocks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LORELOOT" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-pull lore engine for immersive storytelling. Authors mint 'Encrypted Props' (hidden documents, voice logs, or plot items) that remain obscured until a reader settles a 0.01 USDC micro-transaction. Payment triggers a server-side reveal of the metadata and a Base settlement hash, turning narrative discovery into a tangible exchange of value. Perfect for ARGs, tabletop campaigns, and digital novels where truth has a price. Discipline: Writing, Poetry & Narrative (story props and lore). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By placing story lore behind a sub-cent payment wall, writers can monetize the 'deep dive' aspects of their world-building that are usually forgotten in free wikis, while readers treat each reveal as a physical acquisition of a secret. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LORELOOT" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-writer-s-journal-vault-16-x402 Title: Inkstone · x402 Theme: Writing, Poetry & Narrative (writing) · personal writing logs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An immutable timeline for the creative process. Pay $0.01 USDC to commit a writing session snapshot to the ledger. Each commit creates a permanent, HTS transfer signed proof of progress, preventing procrastination via financial stake and building a verifiable provenance for every word written. Why Hedera: By attaching a micropayment to the 'Save' action, the act of writing gains weight and permanence. It transforms a private log into an auditable trail of creative labor, ideal for professional writers who need to prove manuscript history or solo creators seeking a 'Proof of Work' incentive loop. Market: TAM $1.8B — The global digital journaling and productivity software market, pivotally shifting toward blockchain-based IP protection and 'Anti-AI' human-origin proofs. | SAM $250M — The creator economy segment focusing on long-form content, ghostwriters, and academic researchers seeking verifiable draft history. | SOM $12M — Early adopters in the decentralized writing space (Mirror, Paragraph) and students using micropayments as a behavioral 'streak' mechanism. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Inkstone" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An immutable timeline for the creative process. Pay $0.01 USDC to commit a writing session snapshot to the ledger. Each commit creates a permanent, HTS transfer signed proof of progress, preventing procrastination via financial stake and building a verifiable provenance for every word written. Discipline: Writing, Poetry & Narrative (personal writing logs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By attaching a micropayment to the 'Save' action, the act of writing gains weight and permanence. It transforms a private log into an auditable trail of creative labor, ideal for professional writers who need to prove manuscript history or solo creators seeking a 'Proof of Work' incentive loop. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Inkstone" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-screenplay-casting-files-17-x402 Title: CastingCall · x402 Theme: Writing, Poetry & Narrative (writing) · casting and character assets Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered character asset engine for screenwriters. Pay 0.01 USDC to mint a permanent 'Character Dossier'—a cryptographic link between performance notes, AI-generated likenesses, and script metadata. Casting directors pay to unlock 'Performance Rights' views, flowing micropayments directly to the writer's wallet for every talent review. Why Hedera: By shifting from a static database to a pay-per-view/pay-per-mint model, the script's IP becomes a live, revenue-generating asset long before production starts. x402 handles the high-volume, low-friction transfers required for a talent agency's bulk scrolling. Market: TAM $2.1B — The global talent acquisition and creative IP management sector. | SAM $450M — Scripting and pre-production software market (Final Draft, Celtx users). | SOM $12M — Professional screenwriters and independent casting directors on Hedera using automated IP agents. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CastingCall" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered character asset engine for screenwriters. Pay 0.01 USDC to mint a permanent 'Character Dossier'—a cryptographic link between performance notes, AI-generated likenesses, and script metadata. Casting directors pay to unlock 'Performance Rights' views, flowing micropayments directly to the writer's wallet for every talent review. Discipline: Writing, Poetry & Narrative (casting and character assets). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a static database to a pay-per-view/pay-per-mint model, the script's IP becomes a live, revenue-generating asset long before production starts. x402 handles the high-volume, low-friction transfers required for a talent agency's bulk scrolling. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CastingCall" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrative-role-tracker-18-x402 Title: ARCSETTER · x402 Theme: Writing, Poetry & Narrative (writing) · role-based story design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized story engine where narrative arcs are minted as live state. Pay 0.01 USDC to commit a character action, branch a plotline, or lock a role. Every plot twist is a micro-transaction, ensuring contributors are paid instantly when their character archetypes are utilized or referenced in the evolving canon. Why Hedera: Shifts collaborative writing from 'passive tracking' to 'active stake-holding.' By metering character commits, it prevents narrative bloat and compensates world-builders for every lore contribution. Market: TAM $4.2B — The global digital publishing and interactive narrative entertainment market. | SAM $850M — The collaborative fiction, RPG, and fan-fiction creator economy. | SOM $12M — On-chain collaborative writing rooms and decentralized writers' strikes/collectives. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ARCSETTER" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized story engine where narrative arcs are minted as live state. Pay 0.01 USDC to commit a character action, branch a plotline, or lock a role. Every plot twist is a micro-transaction, ensuring contributors are paid instantly when their character archetypes are utilized or referenced in the evolving canon. Discipline: Writing, Poetry & Narrative (role-based story design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts collaborative writing from 'passive tracking' to 'active stake-holding.' By metering character commits, it prevents narrative bloat and compensates world-builders for every lore contribution. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ARCSETTER" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-poetry-event-ledger-19-x402 Title: Stanza · x402 Theme: Writing, Poetry & Narrative (writing) · live poetry events Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-local digital archive for live spoken word. Attendees pay 0.01 USDC to unlock high-fidelity recordings of a specific performance or to mint a timestamped 'Echo'—a digital proof-of-attendance that includes the poet's original manuscript. Pay-per-poem access ensures poets are compensated instantly every time a guest revisits a performance after the lights go out. Why Hedera: By shifting from a static ledger to a micro-gated vault, we transform poetry from a one-time ephemeral event into a recurring revenue stream for performers. Using x402 allows for granular access to individual poems rather than a bulk subscription. Market: TAM $820M — The global performance arts and digital content archiving sector. | SAM $45M — The market for literary festivals, spoken word workshops, and independent publishing platforms. | SOM $1.2M — Targeting high-volume urban poetry slams and university creative writing circuits on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stanza" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-local digital archive for live spoken word. Attendees pay 0.01 USDC to unlock high-fidelity recordings of a specific performance or to mint a timestamped 'Echo'—a digital proof-of-attendance that includes the poet's original manuscript. Pay-per-poem access ensures poets are compensated instantly every time a guest revisits a performance after the lights go out. Discipline: Writing, Poetry & Narrative (live poetry events). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from a static ledger to a micro-gated vault, we transform poetry from a one-time ephemeral event into a recurring revenue stream for performers. Using x402 allows for granular access to individual poems rather than a bulk subscription. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stanza" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrative-device-index-20-x402 Title: Canon Mirror · x402 Theme: Writing, Poetry & Narrative (writing) · literary device cataloging Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A real-time, high-granularity scanner for narrative DNA. Every rhyme scheme, metaphor, or 'Chekhov's Gun' detected in a text is indexed as a paid insight. Writers pay per device breakdown to deconstruct their competition, while scholars stream micropayments to generate the world's most dense crowdsourced corpus of literary techniques. No subscriptions, just 0.01 USDC per device annotation logged to the chain. Why Hedera: Traditional literary analysis is trapped in static PDFs or expensive textbooks. By turning 'device discovery' into a metered on-chain event, we create a liquid market for structural analysis. x402 allows for the 'micro-critique'—paying only for the specific narrative threads you want to pull. Market: TAM $1.2B — The global digital publishing, academic literary research, and AI-assisted narrative generation market. | SAM $85M — The professional creative writing, screenwriting, and high-level editorial market using digital toolsets. | SOM $4.2M — On-chain researchers and speculative fiction writers utilizing Base for provenance and craft validation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Canon Mirror" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A real-time, high-granularity scanner for narrative DNA. Every rhyme scheme, metaphor, or 'Chekhov's Gun' detected in a text is indexed as a paid insight. Writers pay per device breakdown to deconstruct their competition, while scholars stream micropayments to generate the world's most dense crowdsourced corpus of literary techniques. No subscriptions, just 0.01 USDC per device annotation logged to the chain. Discipline: Writing, Poetry & Narrative (literary device cataloging). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional literary analysis is trapped in static PDFs or expensive textbooks. By turning 'device discovery' into a metered on-chain event, we create a liquid market for structural analysis. x402 allows for the 'micro-critique'—paying only for the specific narrative threads you want to pull. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Canon Mirror" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-crowdwritten-saga-21-x402 Title: Inkbound · x402 Theme: Writing, Poetry & Narrative (writing) · crowdsourced storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A collaborative story engine where every sentence is an on-chain transaction. To add a line to the saga, users sign a 0.01 USDC x402 authorization. This micro-fee acts as a sybil-filter and a permanent stake in the narrative. All collected fees are pooled into a 'lore treasury' distributed back to authors whose segments get the most 'reactions' (also 0.01 USDC micropayments). Ownership isn't just claimed; it's funded sentence-by-sentence. Why Hedera: By turning every contribution into a paid micro-transaction, we eliminate bot-spam while creating a literal economy of narrative value. The Hedera transaction id serves as the immutable timestamp for story canon. Market: TAM $850M — The global digital publishing and collaborative writing market, increasingly shifting toward micro-monetization. | SAM $45M — The niche for high-engagement fan fiction and collaborative world-building platforms with creator-fund models. | SOM $1.2M — Initial cohort of web3 writers and RPG world-builders on Hedera seeking structured, paid collaborative tools. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Inkbound" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A collaborative story engine where every sentence is an on-chain transaction. To add a line to the saga, users sign a 0.01 USDC x402 authorization. This micro-fee acts as a sybil-filter and a permanent stake in the narrative. All collected fees are pooled into a 'lore treasury' distributed back to authors whose segments get the most 'reactions' (also 0.01 USDC micropayments). Ownership isn't just claimed; it's funded sentence-by-sentence. Discipline: Writing, Poetry & Narrative (crowdsourced storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning every contribution into a paid micro-transaction, we eliminate bot-spam while creating a literal economy of narrative value. The Hedera transaction id serves as the immutable timestamp for story canon. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Inkbound" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-script-location-scouting-22-x402 Title: SetBound · x402 Theme: Writing, Poetry & Narrative (writing) · visual storytelling assets Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A spatial reconnaissance engine for directors. Metered access to pin high-fidelity location data, GPS coordinates, and aesthetic metadata to scene headers. Every 'Scout' action triggers a 0.01 USDC micro-settlement, instantly routing royalty splits to local fixers or photographers. Lock your cinematography blueprints behind x402 gates for secure studio sharing. Why Hedera: By turning location scouting into a pay-per-pin utility, we monetize the 'fetch' operation of visual research. It transforms a static mood board into a value-accruing asset where every scene-link is a verifiable micro-transaction on Hedera. Market: TAM $2.1B — The global film & media pre-production software market transitioning to real-time collaboration. | SAM $85M — Independent and boutique film production companies utilizing digital pre-production workflows. | SOM $4.2M — On-location scouts and experimental cinematographers on Hedera testnet using decentralized file storage. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SetBound" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A spatial reconnaissance engine for directors. Metered access to pin high-fidelity location data, GPS coordinates, and aesthetic metadata to scene headers. Every 'Scout' action triggers a 0.01 USDC micro-settlement, instantly routing royalty splits to local fixers or photographers. Lock your cinematography blueprints behind x402 gates for secure studio sharing. Discipline: Writing, Poetry & Narrative (visual storytelling assets). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning location scouting into a pay-per-pin utility, we monetize the 'fetch' operation of visual research. It transforms a static mood board into a value-accruing asset where every scene-link is a verifiable micro-transaction on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SetBound" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-story-genre-taxonomy-23-x402 Title: GenreGram · x402 Theme: Writing, Poetry & Narrative (writing) · genre classification Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-fidelity classification engine that tags narrative DNA. Users pay 0.01 USDC to instantly map any prose against a rigid story-type ontology. Once classified, the metadata is immutable, creating a universal 'Genre Passport' for manuscripts that AI agents and libraries can query to automate discovery and distribution. Why Hedera: By turning genre classification into a metered micropayment service, we eliminate 'genre drift' and allow authors to programmatically verify their work's market fit before submission. Market: TAM $4.2B — The global digital publishing and metadata management industry, increasingly governed by algorithmic curation. | SAM $850M — Independent authors and web-novel platforms requiring automated metadata tagging for large-scale content libraries. | SOM $12M — Web3 publishing protocols and AI-driven story aggregators on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GenreGram" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-fidelity classification engine that tags narrative DNA. Users pay 0.01 USDC to instantly map any prose against a rigid story-type ontology. Once classified, the metadata is immutable, creating a universal 'Genre Passport' for manuscripts that AI agents and libraries can query to automate discovery and distribution. Discipline: Writing, Poetry & Narrative (genre classification). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning genre classification into a metered micropayment service, we eliminate 'genre drift' and allow authors to programmatically verify their work's market fit before submission. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GenreGram" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrative-timeline-archive-24-x402 Title: AEONLORE · x402 Theme: Writing, Poetry & Narrative (writing) · chronology management Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A temporal ledger for complex narratives. Writers pay 0.01 USDC to commit a story beat, branch a timeline, or audit character consistency. By making every event a paid sub-cent transaction, the archive becomes a high-integrity source of truth for lore, preventing narrative collapse in sprawling universes. Why Hedera: Chronology management requires high fidelity and permanent sequencing. x402 turns 'saving a plot point' into a micro-settlement, ensuring that only intentional, verified story beats occupy the shared ledger, while providing a micropayment stream for lore-keepers. Market: TAM $680M — The global Creative Writing Software market and IP management industry. | SAM $45M — Professional novelists, screenwriters, and tabletop world-builders requiring immutable version control. | SOM $1.2M — Narrative designers for Web3 games and collaborative storytelling DAOs. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "AEONLORE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A temporal ledger for complex narratives. Writers pay 0.01 USDC to commit a story beat, branch a timeline, or audit character consistency. By making every event a paid sub-cent transaction, the archive becomes a high-integrity source of truth for lore, preventing narrative collapse in sprawling universes. Discipline: Writing, Poetry & Narrative (chronology management). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Chronology management requires high fidelity and permanent sequencing. x402 turns 'saving a plot point' into a micro-settlement, ensuring that only intentional, verified story beats occupy the shared ledger, while providing a micropayment stream for lore-keepers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "AEONLORE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-versevault-0-x402 Title: Stanza · x402 Theme: Writing, Poetry & Narrative (writing) · poetry drafting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-stanza revision sandbox where poets pay 0.01 USDC to 'mint' versions to permanent storage. No subscriptions; just a high-stakes, metered environment for serious wordsmiths where each save settles on-chain. Why Hedera: By turning 'Save' into a micro-transaction, the act of drafting becomes intentional. Poets value their work more when they literally invest in the revision history, creating a verifiable provenance of the creative process. Market: TAM $400M — The global digital publishing and independent author market migrating to sovereign micro-ownership. | SAM $15M — On-chain literary journals, boutique digital publishers, and niche poetry communities. | SOM $200K — Early adopters in the 'crypto-lit' scene using Base for archival-quality creative work. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stanza" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-stanza revision sandbox where poets pay 0.01 USDC to 'mint' versions to permanent storage. No subscriptions; just a high-stakes, metered environment for serious wordsmiths where each save settles on-chain. Discipline: Writing, Poetry & Narrative (poetry drafting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning 'Save' into a micro-transaction, the act of drafting becomes intentional. Poets value their work more when they literally invest in the revision history, creating a verifiable provenance of the creative process. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stanza" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-storystake-1-x402 Title: StoryStake · x402 Theme: Writing, Poetry & Narrative (writing) · interactive narrative Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A hyper-granular branching narrative engine where every plot pivot is an x402 gate. Readers spend 0.01 USDC to 'commit' a character to a path, instantly settling a micro-royalty to the author while minting their choice's hash to Base. No subscriptions—just pay-per-turn episodic immersion where the community's collective spend dictates the canon. Why Hedera: By turning narrative choices into micropayments, the tension of the story is mirrored by the economic 'stake'. It solves the monetization hurdle for indie writers by allowing them to earn per-click rather than per-book, while leveraging HTS transfer for invisible, gasless signing. Market: TAM $24B — The global digital publishing and web-novel industry, shifting toward serialized, interactive content. | SAM $1.2B — The growing market for interactive fiction and 'choose-your-own-adventure' digital platforms. | SOM $45M — Onchain fiction enthusiasts and 'Base Summer' readers ready for frictionless micro-transactions. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "StoryStake" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A hyper-granular branching narrative engine where every plot pivot is an x402 gate. Readers spend 0.01 USDC to 'commit' a character to a path, instantly settling a micro-royalty to the author while minting their choice's hash to Base. No subscriptions—just pay-per-turn episodic immersion where the community's collective spend dictates the canon. Discipline: Writing, Poetry & Narrative (interactive narrative). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning narrative choices into micropayments, the tension of the story is mirrored by the economic 'stake'. It solves the monetization hurdle for indie writers by allowing them to earn per-click rather than per-book, while leveraging HTS transfer for invisible, gasless signing. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "StoryStake" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-scriptsponsor-2-x402 Title: SceneStake · x402 Theme: Writing, Poetry & Narrative (writing) · screenwriting collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes screenwriting relay where every scene, dialogue punch-up, or script doctoring session requires a 0.01 USDC unlock via x402. Co-authors commit edits as signed micropayments, ensuring every word is literally 'bought into' the narrative. Use x402 to meter the collaboration: pay to fork a scene, tip to approve a draft, or stream micro-payments for narrative consulting. Settlement occurs instantly on Hedera, turning the script into a living proof-of-work ledger. Why Hedera: ScriptSponsor moves from passive tracking to active economic stake. By making every edit a 0.01 USDC commit, you eliminate 'too many cooks' syndrome and ensure every contributor has skin in the game. The x402 primitive acts as a narrative gatekeeper, where the flow of capital mirrors the flow of the story. Market: TAM $2.8B — The global scriptwriting and collaborative storytelling software market (SaaS + Crowdfunding). | SAM $45M — The market for independent screenwriters, TV writers, and creative directors using digital collaborative tools. | SOM $1.2M — Early-adopter remote writers' rooms and decentralized film production DAOs on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "SceneStake" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes screenwriting relay where every scene, dialogue punch-up, or script doctoring session requires a 0.01 USDC unlock via x402. Co-authors commit edits as signed micropayments, ensuring every word is literally 'bought into' the narrative. Use x402 to meter the collaboration: pay to fork a scene, tip to approve a draft, or stream micro-payments for narrative consulting. Settlement occurs instantly on Hedera, turning the script into a living proof-of-work ledger. Discipline: Writing, Poetry & Narrative (screenwriting collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: ScriptSponsor moves from passive tracking to active economic stake. By making every edit a 0.01 USDC commit, you eliminate 'too many cooks' syndrome and ensure every contributor has skin in the game. The x402 primitive acts as a narrative gatekeeper, where the flow of capital mirrors the flow of the story. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "SceneStake" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narratenexus-3-x402 Title: PlotStream · x402 Theme: Writing, Poetry & Narrative (writing) · narrative design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A per-node narrative engine where writers monetize story branches via micropayments. Readers pay 0.01 USDC to unlock the next plot choice, revealing content cryptographically signed to their session. Authors earn instant revenue for every 'turn of the page,' turning world-building into a high-frequency liquid market. Why Hedera: Narrative design is often undervalued in bulk; x402 atomizes the value of a single 'beat' or 'choice.' By metering the story, writers get paid for engagement depth rather than just flat access. Market: TAM $5.8B — Global digital publishing and interactive storytelling market. | SAM $420M — Narrative designers and indie authors leveraging decentralized publishing platforms. | SOM $12M — On-chain interactive fiction and 'choose your own adventure' RPG players on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PlotStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A per-node narrative engine where writers monetize story branches via micropayments. Readers pay 0.01 USDC to unlock the next plot choice, revealing content cryptographically signed to their session. Authors earn instant revenue for every 'turn of the page,' turning world-building into a high-frequency liquid market. Discipline: Writing, Poetry & Narrative (narrative design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Narrative design is often undervalued in bulk; x402 atomizes the value of a single 'beat' or 'choice.' By metering the story, writers get paid for engagement depth rather than just flat access. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PlotStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-poemproof-4-x402 Title: Versify · x402 Theme: Writing, Poetry & Narrative (writing) · poetry copyright Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A global authorship ledger where registering a stanza costs $0.01. Every 'Proof of Ink' generates a signed EIP-712 credential that binds your Magic Link email sign-in to the specific byte-hash of the poem. It’s not just a timestamp; it’s a micropayment-gated notary that protects against LLM-scraping and plagiarism by turning authorship into a verifiable, on-chain event. $0.01 to claim it, $0.05 to query the official record. Why Hedera: By shifting from 'zero gas' to '$0.01 USDC', we convert a passive timestamp into a legal-grade digital notarization. The cost prevents spamming the ledger while providing a sustainable revenue model for the facilitator. Payment becomes the 'seal' of authenticity. Market: TAM $920M — The global digital intellectual property and notary market, increasingly shifting toward automated, programmatic verification. | SAM $45M — Estimated annual volume of independent poets and songwriters seeking low-cost copyright alternatives to expensive legal filings. | SOM $1.2M — Capturing the early-adopter 'Creative Tech' niche using HashPack-integrated writing platforms for instant verification. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Versify" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A global authorship ledger where registering a stanza costs $0.01. Every 'Proof of Ink' generates a signed EIP-712 credential that binds your Magic Link email sign-in to the specific byte-hash of the poem. It’s not just a timestamp; it’s a micropayment-gated notary that protects against LLM-scraping and plagiarism by turning authorship into a verifiable, on-chain event. $0.01 to claim it, $0.05 to query the official record. Discipline: Writing, Poetry & Narrative (poetry copyright). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'zero gas' to '$0.01 USDC', we convert a passive timestamp into a legal-grade digital notarization. The cost prevents spamming the ledger while providing a sustainable revenue model for the facilitator. Payment becomes the 'seal' of authenticity. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Versify" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-versevoyage-5-x402 Title: Verse · x402 Theme: Writing, Poetry & Narrative (writing) · poetry contests Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes micropayment stadium for poets. Readers pay 0.01 USDC to 'Upvote' (signed HTS transfer transfer), and poets pay 0.01 USDC to 'Enter' a verse. The protocol aggregates these micro-fees into a winner-takes-all smart contract, settled instantly on Hedera. No subscriptions, just friction-less pay-per-line engagement. Why Hedera: Traditional contests suffer from high entry barriers and manual payout overhead. By turning every interaction—entry, vote, and critique—into a 0.01 USDC atomic transaction, Verse becomes a self-sustaining financial ecosystem where the velocity of micro-capital dictates literary value. Market: TAM $1.2B — The global poetry and creative writing enthusiast market. | SAM $140M — The digital literary and independent publishing market. | SOM $8M — Onchain creators and social-fi micro-donors on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Verse" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes micropayment stadium for poets. Readers pay 0.01 USDC to 'Upvote' (signed HTS transfer transfer), and poets pay 0.01 USDC to 'Enter' a verse. The protocol aggregates these micro-fees into a winner-takes-all smart contract, settled instantly on Hedera. No subscriptions, just friction-less pay-per-line engagement. Discipline: Writing, Poetry & Narrative (poetry contests). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional contests suffer from high entry barriers and manual payout overhead. By turning every interaction—entry, vote, and critique—into a 0.01 USDC atomic transaction, Verse becomes a self-sustaining financial ecosystem where the velocity of micro-capital dictates literary value. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Verse" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-fictionfuel-6-x402 Title: InkSpan · x402 Theme: Writing, Poetry & Narrative (writing) · story ideation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A creative-agent sandbox where every narrative branch is a micro-transaction. Pay 0.01 USDC to prompt the high-context story engine for an 'inciting incident' or 'plot twist.' Writers pay per ideation call, while co-authors receive instant settlement for feedback provided via HTS transfer signed approvals. No subscriptions, just pay-as-you-write logic that turns storytelling into a metered professional service. Why Hedera: FictionFuel transitions from a 'gasless' (subsidized) model to an x402 'pay-per-use' model. By pricing story ideation at the granular level, it eliminates the friction of monthly tiers and uses the Hedera transaction id as a proof-of-contribution for collaborative narrative threads. Market: TAM $4.2B — The global digital publishing and AI-generative content economy. | SAM $850M — The creative writing software market and collaborative platform spend. | SOM $12M — Early adopters in the web3 fiction space and AI-assisted narrative designers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkSpan" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A creative-agent sandbox where every narrative branch is a micro-transaction. Pay 0.01 USDC to prompt the high-context story engine for an 'inciting incident' or 'plot twist.' Writers pay per ideation call, while co-authors receive instant settlement for feedback provided via HTS transfer signed approvals. No subscriptions, just pay-as-you-write logic that turns storytelling into a metered professional service. Discipline: Writing, Poetry & Narrative (story ideation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: FictionFuel transitions from a 'gasless' (subsidized) model to an x402 'pay-per-use' model. By pricing story ideation at the granular level, it eliminates the friction of monthly tiers and uses the Hedera transaction id as a proof-of-contribution for collaborative narrative threads. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkSpan" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-scriptsync-7-x402 Title: Final Draft · x402 Theme: Writing, Poetry & Narrative (writing) · screenplay versioning Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — pay-per-commit screenplay versioning. Script writers and production offices pay every time a scene is locked, revised, or distributed. No subscriptions: pay exactly for the structural evolution of the narrative. Each x402 settlement generates a permanent Base transaction hash that acts as a cryptographically verifiable timestamp for WGA protection and chain-of-title audits. Pay per sync, pay per draft, pay per greenlight. Why Hedera: Legacy screenwriting software relies on expensive yearly SaaS models that don't reflect the 'bursty' nature of script revisions. By turning every 'Sync' or 'Version Lock' into a $0.01 HTS transfer transaction, we create a micro-scale chain of title. This proves exactly who wrote what and when, settled instantly on-chain, making legal discovery in Hollywood a one-click process. Market: TAM $550M — The global entertainment scriptwriting software and legal clearance market. | SAM $85M — Independent film productions, TV writers, and guild members requiring immutable version control. | SOM $4M — Web3-native filmmakers, collaborative writers' rooms, and indie creators using decentralized storage. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Final Draft" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — pay-per-commit screenplay versioning. Script writers and production offices pay every time a scene is locked, revised, or distributed. No subscriptions: pay exactly for the structural evolution of the narrative. Each x402 settlement generates a permanent Base transaction hash that acts as a cryptographically verifiable timestamp for WGA protection and chain-of-title audits. Pay per sync, pay per draft, pay per greenlight. Discipline: Writing, Poetry & Narrative (screenplay versioning). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Legacy screenwriting software relies on expensive yearly SaaS models that don't reflect the 'bursty' nature of script revisions. By turning every 'Sync' or 'Version Lock' into a $0.01 HTS transfer transaction, we create a micro-scale chain of title. This proves exactly who wrote what and when, settled instantly on-chain, making legal discovery in Hollywood a one-click process. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Final Draft" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrativenest-8-x402 Title: CanonGate · x402 Theme: Writing, Poetry & Narrative (writing) · world-building Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A modular lore-engine where every world-building contribution—from naming a star to defining a magic system—is a 0.01 USDC micro-transaction. Pay to mint a fact, tip to canonize a branch, or charge AI agents to scan your world's 'Bible' for their own narratives. Lore is no longer just text; it is a metered asset class. Why Hedera: Shifting from 'free collaborative editing' to 'pay-per-edit' filters for high-quality contributions and treats world-building as a persistent capital asset. x402 allows for granular ownership where every 'save' to the world-state is a settled transaction, creating a literal 'proof-of-lore' economy. Market: TAM $140B — The global intellectual property and entertainment licensing market, increasingly moving toward decentralized co-creation. | SAM $950M — The burgeoning market for collaborative storytelling platforms, tabletop RPG digital tools, and fan-fiction communities. | SOM $12M — Onchain RPG developers and DAO-based creative collectives using Base for high-frequency state updates. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "CanonGate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A modular lore-engine where every world-building contribution—from naming a star to defining a magic system—is a 0.01 USDC micro-transaction. Pay to mint a fact, tip to canonize a branch, or charge AI agents to scan your world's 'Bible' for their own narratives. Lore is no longer just text; it is a metered asset class. Discipline: Writing, Poetry & Narrative (world-building). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifting from 'free collaborative editing' to 'pay-per-edit' filters for high-quality contributions and treats world-building as a persistent capital asset. x402 allows for granular ownership where every 'save' to the world-state is a settled transaction, creating a literal 'proof-of-lore' economy. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "CanonGate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-versevouch-9-x402 Title: VerseVouch · x402 Theme: Writing, Poetry & Narrative (writing) · poetry endorsement Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Micro-curate the literary canon. Sign an HTS transfer permit to affix a $0.01 'Proof of Resonance' to any poem. Your micropayment acts as a permanent, weighted endorsement on-chain, creating a high-signal discovery feed where impact is measured in settled USDC, not empty likes. Why Hedera: By attaching a nominal cost ($0.01) to an 'endorsement,' we transform a low-intent social action into a high-signal economic vote. x402 allows readers to 'tip' poetry into visibility without friction, while ensuring poets receive streaming micropayments for their work. Market: TAM $2.1B — The global creative economy for independent writers and the burgeoning 'AI-reader' curation market. | SAM $450M — The digital literary and independent publishing market moving toward micro-monetization. | SOM $12M — Early adopters in the Farcaster/Lens ecosystem and Web3 poetry circles (e.g., the 'theVERSEverse' community). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VerseVouch" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Micro-curate the literary canon. Sign an HTS transfer permit to affix a $0.01 'Proof of Resonance' to any poem. Your micropayment acts as a permanent, weighted endorsement on-chain, creating a high-signal discovery feed where impact is measured in settled USDC, not empty likes. Discipline: Writing, Poetry & Narrative (poetry endorsement). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By attaching a nominal cost ($0.01) to an 'endorsement,' we transform a low-intent social action into a high-signal economic vote. x402 allows readers to 'tip' poetry into visibility without friction, while ensuring poets receive streaming micropayments for their work. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VerseVouch" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-plotpledge-10-x402 Title: PlotPulse · x402 Theme: Writing, Poetry & Narrative (writing) · crowdfund writing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Bypass traditional crowdfunding fatigue with micro-pledges. Unlock the next paragraph, character beat, or plot twist of a live-writing session for 0.01 USDC. Each payment is a direct signal of interest that settles instantly to the author, turning readers into real-time executive producers of the narrative. Why Hedera: By shifting from 'large-sum pledges' to 'per-beat micropayments,' the friction of commitment is removed. x402 allows readers to steer the story dynamically, creating a pay-per-turn interaction model that sustains the writer continuously rather than via a single risky campaign. Market: TAM $12.5B — Global crowdfunding and digital literature market, merging the 'creator middle class' with frictionless on-chain settlements. | SAM $850M — The share of the creator economy specifically driven by serialized fiction and interactive storytelling platforms. | SOM $12M — The projected volume of micro-transaction narrative steers within the Base/HashPack ecosystem and AI-generated collaborative fiction. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PlotPulse" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Bypass traditional crowdfunding fatigue with micro-pledges. Unlock the next paragraph, character beat, or plot twist of a live-writing session for 0.01 USDC. Each payment is a direct signal of interest that settles instantly to the author, turning readers into real-time executive producers of the narrative. Discipline: Writing, Poetry & Narrative (crowdfund writing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'large-sum pledges' to 'per-beat micropayments,' the friction of commitment is removed. x402 allows readers to steer the story dynamically, creating a pay-per-turn interaction model that sustains the writer continuously rather than via a single risky campaign. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PlotPulse" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narratenft-11-x402 Title: Loom · x402 Theme: Writing, Poetry & Narrative (writing) · narrative NFTs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Write-to-earn meets pay-to-read. A decentralized narrative engine where every story branch, character trait, and plot twist is locked behind an x402 gate. Readers pay 0.01 USDC to unlock the next 'page' or vote on a narrative fork, triggering an instant HTS transfer transfer that distributes royalties to the collective authors of that specific thread. Narrative NFTs become productive assets that accrue USDC every time they are accessed. Why Hedera: Transitioning from 'ownership' to 'access-metering' creates a sustainable micro-economy for writers. Instead of one-time minting fees, authors earn recurring revenue per reader interaction, managed by the embedded wallet's seamless gasless signing. Market: TAM $35B — The global digital publishing and subscription e-book market. | SAM $1.2B — The growing market for interactive fiction, web-novels, and fan-fiction platforms. | SOM $18M — Targeted spend from crypto-native readers and DAO-governed collective writing projects on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Loom" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Write-to-earn meets pay-to-read. A decentralized narrative engine where every story branch, character trait, and plot twist is locked behind an x402 gate. Readers pay 0.01 USDC to unlock the next 'page' or vote on a narrative fork, triggering an instant HTS transfer transfer that distributes royalties to the collective authors of that specific thread. Narrative NFTs become productive assets that accrue USDC every time they are accessed. Discipline: Writing, Poetry & Narrative (narrative NFTs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from 'ownership' to 'access-metering' creates a sustainable micro-economy for writers. Instead of one-time minting fees, authors earn recurring revenue per reader interaction, managed by the embedded wallet's seamless gasless signing. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Loom" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-scriptsponsorswap-12-x402 Title: FadeIn · x402 Theme: Writing, Poetry & Narrative (writing) · screenwriter patronage Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Every scene is a micro-unit of equity. Pay 0.01 USDC to unlock the next page of a live screenplay, giving writers instant liquidity per reader-beat while the facilitator settles the script-gate on Hedera. Why Hedera: By turning the script into a pay-per-page or pay-per-scene experience, the screenwriter receives a stream of 0.01 USDC payments as the reader progresses, turning consumption into direct, granular patronage. Market: TAM $2.8B — Global screenwriting and script-consultancy market transitioning to digital-first fractional ownership. | SAM $420M — Emerging onchain screenwriting communities and independent script libraries. | SOM $15M — Early-stage script consultants and indie film enthusiasts on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FadeIn" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Every scene is a micro-unit of equity. Pay 0.01 USDC to unlock the next page of a live screenplay, giving writers instant liquidity per reader-beat while the facilitator settles the script-gate on Hedera. Discipline: Writing, Poetry & Narrative (screenwriter patronage). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the script into a pay-per-page or pay-per-scene experience, the screenwriter receives a stream of 0.01 USDC payments as the reader progresses, turning consumption into direct, granular patronage. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FadeIn" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-versevaultlive-13-x402 Title: VerseVault · x402 Theme: Writing, Poetry & Narrative (writing) · live poetry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: VerseVault turns spoken word into a metered stream. Listeners pay 0.01 USDC per stanza to 'unlock' the live transcript and collaborative sidebar. Every line written or react given by the audience is a micropayment settled instantly to the poet's wallet. It's a high-stakes, pay-as-you-perceive rhythmic exchange where the intensity of the crowd's spend dictates the length of the set. Why Hedera: Moving from 'free-for-all' social to 'per-stanza' monetization creates a direct economic link between the poet's cadence and their revenue. Using x402 allows for granular support that is too small for credit cards but perfect for high-velocity live performance. Market: TAM $4.2B — The creator economy segment for live performance and spoken word media. | SAM $850M — The global digital poetry and spoken word event market. | SOM $12M — On-chain performance artists and 'Poetry Foundation' enthusiasts transitioning to web3 patronage. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VerseVault" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT VerseVault turns spoken word into a metered stream. Listeners pay 0.01 USDC per stanza to 'unlock' the live transcript and collaborative sidebar. Every line written or react given by the audience is a micropayment settled instantly to the poet's wallet. It's a high-stakes, pay-as-you-perceive rhythmic exchange where the intensity of the crowd's spend dictates the length of the set. Discipline: Writing, Poetry & Narrative (live poetry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'free-for-all' social to 'per-stanza' monetization creates a direct economic link between the poet's cadence and their revenue. Using x402 allows for granular support that is too small for credit cards but perfect for high-velocity live performance. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VerseVault" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrativenodes-14-x402 Title: PlotTwist · x402 Theme: Writing, Poetry & Narrative (writing) · story graph design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless story engine where every narrative branch is a tradeable node. Writers and AI agents pay 0.01 USDC to 'expand' the plot or 'fork' a timeline. High-stakes collaborative world-building where you don't just write—you buy into the canon. Each micro-payment secures the state on Hedera, ensuring the plot cannot be rugs-pulled by a central editor. Why Hedera: By pricing 'branching' and 'editing' at the micro-level, the story graph becomes a self-filtering curated asset. It prevents spam while allowing for the emergence of a proof-of-stake narrative where the most valuable plot points garner the most micropayment-driven traffic. Market: TAM $160B — The global digital publishing and interactive fiction market, increasingly shifting toward decentralized IP. | SAM $1.4B — Substack and Patreon power-users migrating to modular, programmable creative ownership. | SOM $14M — Early-adopter visual novelists, AI RPG developers, and collaborative lore-builders on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PlotTwist" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless story engine where every narrative branch is a tradeable node. Writers and AI agents pay 0.01 USDC to 'expand' the plot or 'fork' a timeline. High-stakes collaborative world-building where you don't just write—you buy into the canon. Each micro-payment secures the state on Hedera, ensuring the plot cannot be rugs-pulled by a central editor. Discipline: Writing, Poetry & Narrative (story graph design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By pricing 'branching' and 'editing' at the micro-level, the story graph becomes a self-filtering curated asset. It prevents spam while allowing for the emergence of a proof-of-stake narrative where the most valuable plot points garner the most micropayment-driven traffic. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PlotTwist" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-echoedit-15-x402 Title: Echo · x402 Theme: Writing, Poetry & Narrative (writing) · peer feedback Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes peer-review protocol where feedback is metered by value. Pay 0.01 USDC to unlock a precise critique from a verified peer. Each margin note, structural suggestion, or stanza audit is an atomic payment, ensuring poets and editors are compensated instantly for their intellectual labor. No more free 'looks'—professional eyes for micropayment prices. Why Hedera: Feedback loops in creative writing often suffer from a 'labor for exposure' trap. By turning every critique into an HTS transfer micro-settlement, we quantify the value of peer review. x402 eliminates the friction of bulk subscriptions, allowing writers to pay only for the feedback they consume while building a reputation-backed revenue stream for editors. Market: TAM $1.2B — The global academic and creative peer-review market, inclusive of AI-assisted narrative verification. | SAM $180M — The gig-economy editing market and creative writing workshop sector transitioning to onchain micro-tasks. | SOM $12M — Web3-native writers, substack creators, and decentralized fiction communities seeking high-signal feedback. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Echo" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes peer-review protocol where feedback is metered by value. Pay 0.01 USDC to unlock a precise critique from a verified peer. Each margin note, structural suggestion, or stanza audit is an atomic payment, ensuring poets and editors are compensated instantly for their intellectual labor. No more free 'looks'—professional eyes for micropayment prices. Discipline: Writing, Poetry & Narrative (peer feedback). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Feedback loops in creative writing often suffer from a 'labor for exposure' trap. By turning every critique into an HTS transfer micro-settlement, we quantify the value of peer review. x402 eliminates the friction of bulk subscriptions, allowing writers to pay only for the feedback they consume while building a reputation-backed revenue stream for editors. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Echo" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-plotpassport-16-x402 Title: InkLock · x402 Theme: Writing, Poetry & Narrative (writing) · story ownership Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Turn plot-squatting into a high-velocity speculative market. Pay 0.01 USDC to timestamp and cryptographically anchor a narrative premise to your wallet. Every time another writer 'branches' or references your anchor, the x402 protocol handles the micro-settlement. Writing isn't just expression; it's a metered claim on the global story graph. Why Hedera: By shifting from 'free proof' to 'paid anchoring,' the act of registration gains economic weight. If it costs to claim, the signal-to-noise ratio improves, and the HTS transfer flow makes 'protecting an idea' as low-friction as a keystroke. Market: TAM $280B — The global Intellectual Property and Publishing industry, moving toward micro-attribution. | SAM $1.4B — The nascent 'Agentic Content' market where AI agents pay to license human-authored narrative seeds to prevent hallucination cycles. | SOM $18M — Targeted spend from independent screenwriters and web-fiction authors using Base to secure IP before public posting. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkLock" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Turn plot-squatting into a high-velocity speculative market. Pay 0.01 USDC to timestamp and cryptographically anchor a narrative premise to your wallet. Every time another writer 'branches' or references your anchor, the x402 protocol handles the micro-settlement. Writing isn't just expression; it's a metered claim on the global story graph. Discipline: Writing, Poetry & Narrative (story ownership). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'free proof' to 'paid anchoring,' the act of registration gains economic weight. If it costs to claim, the signal-to-noise ratio improves, and the HTS transfer flow makes 'protecting an idea' as low-friction as a keystroke. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkLock" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-versevibe-17-x402 Title: Stanza · x402 Theme: Writing, Poetry & Narrative (writing) · poetry social feed Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-throughput 'Pay-to-Read' poetry terminal where every stanza is hidden behind a 0.01 USDC gate. Verse uses x402 to automate frictionless micropayments, allowing poets to monetize specific lines or complete sonnets instantly. Readers sign with the embedded wallet to stream payments as they scroll, effectively 'buying the ink' as they consume the narrative. No subscriptions, just a direct flow of value from the reader's gaze to the writer's wallet. Why Hedera: VerseVibe's original 'tipping' model is passive. By making the verse itself the asset gated by x402, we turn poetry into a metered commodity. The friction of traditional payments is solved by the 0.01 USDC primitive, turning a social feed into a high-velocity revenue engine for writers. Market: TAM $2.8B — The global online poetry and literature market transitioning to agent-to-human and peer-to-peer micropayment architectures. | SAM $450M — The digital creative writing and self-publishing market adopting web3-native micro-monetization. | SOM $12M — Poets and micro-fiction writers on Hedera seeking instant HTS transfer settlement for short-form content. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stanza" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-throughput 'Pay-to-Read' poetry terminal where every stanza is hidden behind a 0.01 USDC gate. Verse uses x402 to automate frictionless micropayments, allowing poets to monetize specific lines or complete sonnets instantly. Readers sign with the embedded wallet to stream payments as they scroll, effectively 'buying the ink' as they consume the narrative. No subscriptions, just a direct flow of value from the reader's gaze to the writer's wallet. Discipline: Writing, Poetry & Narrative (poetry social feed). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: VerseVibe's original 'tipping' model is passive. By making the verse itself the asset gated by x402, we turn poetry into a metered commodity. The friction of traditional payments is solved by the 0.01 USDC primitive, turning a social feed into a high-velocity revenue engine for writers. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stanza" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-scriptstand-18-x402 Title: ScriptFlow · x402 Theme: Writing, Poetry & Narrative (writing) · script marketplace Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A granular script-reader marketplace where users stream 0.01 USDC per page or plot-twist. Writers get paid instantly per 'read' event through x402-gated IP portals. No subscriptions; just pay for what you produce or consume. Why Hedera: Moving from a 'storefront' to a 'metered consumption' model turns scripts into high-velocity liquid assets. Every scene becomes a micro-transaction, allowing for fair valuation of long-form vs. short-form content. Market: TAM $15B — Global scriptwriting and literary rights market. | SAM $800M — The creative professional gig economy and independent screenwriter market. | SOM $12M — Web3 scriptwriters and indie filmmakers pilot-testing scripts via Base. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScriptFlow" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A granular script-reader marketplace where users stream 0.01 USDC per page or plot-twist. Writers get paid instantly per 'read' event through x402-gated IP portals. No subscriptions; just pay for what you produce or consume. Discipline: Writing, Poetry & Narrative (script marketplace). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a 'storefront' to a 'metered consumption' model turns scripts into high-velocity liquid assets. Every scene becomes a micro-transaction, allowing for fair valuation of long-form vs. short-form content. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScriptFlow" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-storysprint-19-x402 Title: InkMeter · x402 Theme: Writing, Poetry & Narrative (writing) · timed writing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Compete in high-stakes writing sprints where every prompt is gated and every submission is a micro-transactional event. 0.01 USDC grants entry to a 5-minute 'flow state' window. If you don't finish, you don't commit. If you do, your narrative is permanently etched onto Base with a verifiable timestamp. Pay per prompt, pay per publish. Why Hedera: By shifting from 'free' to 'pay-per-sprint', you eliminate bot noise and create a skin-in-the-game environment for writers. x402 handles the 'toll' for the timed session, effectively making the act of writing a metered, high-intent creative exercise. Market: TAM $2.8B — The global online digital publishing and creative writing market. | SAM $45M — Niche creative writing communities, NaNoWriMo participants, and digital poets. | SOM $1.2M — Onchain writers and social-fi power users on Hedera/Farcaster. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkMeter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Compete in high-stakes writing sprints where every prompt is gated and every submission is a micro-transactional event. 0.01 USDC grants entry to a 5-minute 'flow state' window. If you don't finish, you don't commit. If you do, your narrative is permanently etched onto Base with a verifiable timestamp. Pay per prompt, pay per publish. Discipline: Writing, Poetry & Narrative (timed writing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from 'free' to 'pay-per-sprint', you eliminate bot noise and create a skin-in-the-game environment for writers. x402 handles the 'toll' for the timed session, effectively making the act of writing a metered, high-intent creative exercise. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkMeter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-echoessays-20-x402 Title: Echo · x402 Theme: Writing, Poetry & Narrative (writing) · narrative essays Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-read narrative sandbox where users unlock essays for 0.01 USDC. Every HTS transfer signature facilitates a micro-settlement directly to the author's wallet, turning digital prose into a metered asset. No subscriptions, no gatekeepers—just high-fidelity narrative content paid for by the paragraph or the piece. Why Hedera: By removing the friction of high gas fees and traditional paywalls, x402 enables a 'nanopayment' model for literature. Writers receive instant liquidity for their work, and readers only pay for what they consume, with provenance baked into the settlement hash. Market: TAM $2.8B — Global digital publishing and creative writing economy transitioning to granular monetization. | SAM $450M — The independent newsletter and micro-publishing market (Substack/Ghost creators). | SOM $12M — On-chain long-form writers and narrative NFT collectors on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Echo" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-read narrative sandbox where users unlock essays for 0.01 USDC. Every HTS transfer signature facilitates a micro-settlement directly to the author's wallet, turning digital prose into a metered asset. No subscriptions, no gatekeepers—just high-fidelity narrative content paid for by the paragraph or the piece. Discipline: Writing, Poetry & Narrative (narrative essays). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By removing the friction of high gas fees and traditional paywalls, x402 enables a 'nanopayment' model for literature. Writers receive instant liquidity for their work, and readers only pay for what they consume, with provenance baked into the settlement hash. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Echo" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-versevest-21-x402 Title: Stanza · x402 Theme: Writing, Poetry & Narrative (writing) · poetry royalties Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A decentralized reading protocol where every stanza is metered. Unlock poetic works line-by-line via 0.01 USDC micropayments, routing instant royalties directly to the poet's wallet. Readers pay for the consumption they actualize; poets get paid for every eye on the page, bypassing the 'free-to-read' exploitation of modern social media. Why Hedera: Transitioning from 'royalty management' (backend) to 'metered reading' (frontend) turns every interaction into a liquidity event. Using x402 allows for granular monetization where a user might pay $0.05 to finish a sonnet, a friction-less experience that replaces traditional subscriptions. Market: TAM $2.1B — The global poetry and creative writing market transitioning to per-unit digital authorship. | SAM $450M — The digital literary and long-form content market migrating to on-chain distribution. | SOM $12M — Poets and indie publishers on Hedera seeking sub-dollar monetization for niche audiences. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stanza" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A decentralized reading protocol where every stanza is metered. Unlock poetic works line-by-line via 0.01 USDC micropayments, routing instant royalties directly to the poet's wallet. Readers pay for the consumption they actualize; poets get paid for every eye on the page, bypassing the 'free-to-read' exploitation of modern social media. Discipline: Writing, Poetry & Narrative (poetry royalties). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Transitioning from 'royalty management' (backend) to 'metered reading' (frontend) turns every interaction into a liquidity event. Using x402 allows for granular monetization where a user might pay $0.05 to finish a sonnet, a friction-less experience that replaces traditional subscriptions. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stanza" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narratenestnft-22-x402 Title: LoreLayer · x402 Theme: Writing, Poetry & Narrative (writing) · world-building NFTs Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Establish sovereign lore one brick at a time. Pay 0.01 USDC to 'Commit to Canon'—each micro-payment cryptographically anchors a world-building detail, character trait, or map coordinate into a collective on-chain grimoire. No gas, just lore. Unlock premium mythos by streaming cents to original world-architects. Why Hedera: By making world-building a pay-per-entry activity, we filter out spam and assign tangible value to narrative consistency. It transforms static NFTs into dynamic, metered contributions where 'canon' is bought with micro-consensus. Market: TAM $2.8B — Global fantasy IP and collaborative storytelling platforms. | SAM $450M — The creative writing and online roleplaying market transitioning to Web3. | SOM $12M — Narrative designers and collaborative world-builders on Hedera seeking micro-monetization. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LoreLayer" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Establish sovereign lore one brick at a time. Pay 0.01 USDC to 'Commit to Canon'—each micro-payment cryptographically anchors a world-building detail, character trait, or map coordinate into a collective on-chain grimoire. No gas, just lore. Unlock premium mythos by streaming cents to original world-architects. Discipline: Writing, Poetry & Narrative (world-building NFTs). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making world-building a pay-per-entry activity, we filter out spam and assign tangible value to narrative consistency. It transforms static NFTs into dynamic, metered contributions where 'canon' is bought with micro-consensus. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LoreLayer" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-plotpulse-23-x402 Title: PlotPulse · x402 Theme: Writing, Poetry & Narrative (writing) · story analytics Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Access granular reader heatmap data for every paragraph of your manuscript. Writers pay-per-report to see exactly where readers drop off; readers earn 0.01 USDC back via HTS transfer for every chapter they 'deep-read' and annotate, creating a liquid market for high-fidelity narrative feedback. Plot feedback isn't a survey; it's a metered data stream. Why Hedera: By turning reader attention into a paid primitive, we eliminate the noise of passive skimming. Authors spend USDC to acquire high-intent engagement data, and readers are compensated for the labor of critique, all settled in sub-cent increments on Hedera. Market: TAM $4.2B — The global narrative economy, including professional scriptwriting, technical documentation, and AI-assisted fiction development. | SAM $850M — The independent publishing and sub-author analytics market shifting toward data-driven editing. | SOM $12M — Early-adopter serialized fiction platforms (Substack, Wattpad power users) integrating micro-reward feedback loops. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PlotPulse" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Access granular reader heatmap data for every paragraph of your manuscript. Writers pay-per-report to see exactly where readers drop off; readers earn 0.01 USDC back via HTS transfer for every chapter they 'deep-read' and annotate, creating a liquid market for high-fidelity narrative feedback. Plot feedback isn't a survey; it's a metered data stream. Discipline: Writing, Poetry & Narrative (story analytics). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning reader attention into a paid primitive, we eliminate the noise of passive skimming. Authors spend USDC to acquire high-intent engagement data, and readers are compensated for the labor of critique, all settled in sub-cent increments on Hedera. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PlotPulse" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-scriptsphere-24-x402 Title: DraftBeat · x402 Theme: Writing, Poetry & Narrative (writing) · script feedback Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-stakes writers room where every critique has skin in the game. Writers deposit 0.01 USDC to unlock an AI-assisted line-edit or a peer breakdown. Evaluators earn micro-dividends for every 'unlock' their feedback generates. This turns script coverage from a favor into a high-velocity feedback market on Hedera. Why Hedera: By making feedback a metered x402 transaction, you eliminate the 'ghosting' common in peer review. The micropayment acts as a proof-of-attention, ensuring only serious analysts engage with the text. Market: TAM $4.2B — The creator economy for long-form narrative, film, and digital storytelling infrastructure. | SAM $650M — The global script writing and pre-production software market. | SOM $12M — Independent screenwriters and playwrights seeking immediate, affordable coverage over expensive industry gatekeepers. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DraftBeat" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-stakes writers room where every critique has skin in the game. Writers deposit 0.01 USDC to unlock an AI-assisted line-edit or a peer breakdown. Evaluators earn micro-dividends for every 'unlock' their feedback generates. This turns script coverage from a favor into a high-velocity feedback market on Hedera. Discipline: Writing, Poetry & Narrative (script feedback). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making feedback a metered x402 transaction, you eliminate the 'ghosting' common in peer review. The micropayment acts as a proof-of-attention, ensuring only serious analysts engage with the text. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DraftBeat" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-verse-vault-0-x402 Title: Couplet · x402 Theme: Writing, Poetry & Narrative (writing) · poetry archiving Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A high-frequency literary ledger where every stanza is a transaction. Readers pay $0.01 USDC to unlock an original poem, and poets pay $0.01 USDC to cryptographically timestamp a new work into the vault. No subscriptions, just a micro-toll for every act of creation and consumption, ensuring the permanence of the written word via frictionless Base settlements. Why Hedera: By making both entry (writing) and exit (reading) a per-use x402 call, we turn poetry from a passive asset into a metered stream of value. HTS transfer allows for instant, headless micro-transactions that facilitate 'pay-per-line' mechanics. Market: TAM $2.4B — The global poetry and creative writing market, shifting toward direct creator-to-consumer micro-payments. | SAM $180M — The digital literary and independent publishing market moving toward micro-monetization. | SOM $12M — On-chain poets and decentralized social media users (Farcaster/Lens) seeking per-view revenue. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Couplet" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A high-frequency literary ledger where every stanza is a transaction. Readers pay $0.01 USDC to unlock an original poem, and poets pay $0.01 USDC to cryptographically timestamp a new work into the vault. No subscriptions, just a micro-toll for every act of creation and consumption, ensuring the permanence of the written word via frictionless Base settlements. Discipline: Writing, Poetry & Narrative (poetry archiving). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By making both entry (writing) and exit (reading) a per-use x402 call, we turn poetry from a passive asset into a metered stream of value. HTS transfer allows for instant, headless micro-transactions that facilitate 'pay-per-line' mechanics. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Couplet" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrative-nexus-1-x402 Title: LOREGATE · x402 Theme: Writing, Poetry & Narrative (writing) · interactive storytelling Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-choice narrative engine where every plot turn costs 0.01 USDC. Readers micropay to 'unlock' the next node, triggering a real-time settlement to the author's wallet. Write branches that only reveal themselves when the network pays to see them. Authors earn instantly as stories go viral, and automated agents can be tuned to 'read' and solve narrative puzzles by paying the protocol fee. Why Hedera: By moving from NFT ownership to pay-per-read micro-settlement, the friction of 'buying a book' is replaced by the flow of 'buying the next page.' This turns storytelling into a metered utility rather than a static asset. Market: TAM $2.1B — The global digital publishing and subscription economy transitioning to per-unit consumption models. | SAM $480M — The interactive fiction and 'choose your own adventure' digital app market. | SOM $12M — On-chain literary enthusiasts and AI-driven narrative agents executing micro-transactions on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "LOREGATE" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-choice narrative engine where every plot turn costs 0.01 USDC. Readers micropay to 'unlock' the next node, triggering a real-time settlement to the author's wallet. Write branches that only reveal themselves when the network pays to see them. Authors earn instantly as stories go viral, and automated agents can be tuned to 'read' and solve narrative puzzles by paying the protocol fee. Discipline: Writing, Poetry & Narrative (interactive storytelling). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from NFT ownership to pay-per-read micro-settlement, the friction of 'buying a book' is replaced by the flow of 'buying the next page.' This turns storytelling into a metered utility rather than a static asset. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "LOREGATE" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-script-stamp-2-x402 Title: FinalDraft Trust · x402 Theme: Writing, Poetry & Narrative (writing) · screenwriting rights Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-version professional provenance for screenwriters. Instead of high-friction NFT minting, writers sign 0.01 USDC x402 calls to 'stamp' screenplay drafts. Each stamp provides a cryptographic proof-of-existence and immutable time-record on Hedera. Producers and agents pay a 0.05 USDC micro-fee to unlock a watermarked PDF for reading, with payments flowing directly to the writer’s the embedded wallet-secured wallet. It turns the script ledger into a high-velocity, metered rights-management engine. Why Hedera: By shifting from bulky NFT minting to x402 micropayments, we remove the technical overhead for non-crypto writers while enabling a new 'pay-to-read' primitive. The facilitator handles the gas, and the writer pays only for the 'stamp' action, while readers pay to 'unlock.' Market: TAM $2.8B — The total addressable market for global digital copyright management and entertainment IP licensing. | SAM $450M — The global production and intellectual property protection market for independent creators and mid-tier screenwriters. | SOM $12M — Professional screenwriters and script doctors on Hedera using x402 for version-control provenance. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "FinalDraft Trust" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-version professional provenance for screenwriters. Instead of high-friction NFT minting, writers sign 0.01 USDC x402 calls to 'stamp' screenplay drafts. Each stamp provides a cryptographic proof-of-existence and immutable time-record on Hedera. Producers and agents pay a 0.05 USDC micro-fee to unlock a watermarked PDF for reading, with payments flowing directly to the writer’s the embedded wallet-secured wallet. It turns the script ledger into a high-velocity, metered rights-management engine. Discipline: Writing, Poetry & Narrative (screenwriting rights). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from bulky NFT minting to x402 micropayments, we remove the technical overhead for non-crypto writers while enabling a new 'pay-to-read' primitive. The facilitator handles the gas, and the writer pays only for the 'stamp' action, while readers pay to 'unlock.' 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "FinalDraft Trust" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-poet-s-provenance-3-x402 Title: Stanza · x402 Theme: Writing, Poetry & Narrative (writing) · lyric poetry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-line ledger for lyric poetry. Readers pay 0.01 USDC to unlock the next stanza of an ongoing poem, while creators earn micro-royalties every time a line is cited or 'sampled' into another user's narrative. Payment is the mechanism of revelation and the proof of origin. Why Hedera: By turning the poem into a metered experience, we solve the 'infinite scroll' devaluation of text. x402 enables a revenue model where the poem is a live asset that charges for visibility and attribution, liquidating the concept of 'provenance' into real-time micro-payments. Market: TAM $2.1B — The global creative writing and self-publishing economy shifting toward granular, agent-to-agent licensing. | SAM $140M — The digital publishing and independent poetry market adopting micropayment-gated 'drip' content. | SOM $8M — On-chain writers and social-fi users on Hedera seeking verifiable attribution for short-form lyricism. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stanza" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-line ledger for lyric poetry. Readers pay 0.01 USDC to unlock the next stanza of an ongoing poem, while creators earn micro-royalties every time a line is cited or 'sampled' into another user's narrative. Payment is the mechanism of revelation and the proof of origin. Discipline: Writing, Poetry & Narrative (lyric poetry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the poem into a metered experience, we solve the 'infinite scroll' devaluation of text. x402 enables a revenue model where the poem is a live asset that charges for visibility and attribution, liquidating the concept of 'provenance' into real-time micro-payments. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stanza" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-fable-frame-4-x402 Title: Fable Frame · x402 Theme: Writing, Poetry & Narrative (writing) · children’s narratives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A collaborative storytelling engine where each plot branch, character twist, or moral lesson is a 0.01 USDC unlock. Parents and authors co-create persistent digital fables where every 'next page' is a micro-settlement, ensuring the illustrator and writer are paid per reader engagement rather than just at the point of sale. Why Hedera: Moving from a static NFT mint to a state-based payment model turns children's stories into a living service. x402 allows for 'metered bedtime stories' where creators earn cumulative revenue for every interaction or branch explored by the reader. Market: TAM $9.5B — The global digital publishing and interactive children’s media market. | SAM $450M — On-chain creators and AI-native parents seeking quality, authenticated children's content. | SOM $12M — Early adopters of Base using HashPack-linked wallets for seamless micro-narrative consumption. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Fable Frame" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A collaborative storytelling engine where each plot branch, character twist, or moral lesson is a 0.01 USDC unlock. Parents and authors co-create persistent digital fables where every 'next page' is a micro-settlement, ensuring the illustrator and writer are paid per reader engagement rather than just at the point of sale. Discipline: Writing, Poetry & Narrative (children’s narratives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from a static NFT mint to a state-based payment model turns children's stories into a living service. x402 allows for 'metered bedtime stories' where creators earn cumulative revenue for every interaction or branch explored by the reader. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Fable Frame" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-epic-editions-5-x402 Title: Inkstream · x402 Theme: Writing, Poetry & Narrative (writing) · long-form fiction Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless publishing protocol where readers stream long-form fiction. Readers pay 0.05 USDC per chapter to unlock the next 'beat' in an evolving draft. Authors receive instant settlement for every page turn, while AI summarizers pay 0.01 USDC per call to index the plot for hyper-personalized discovery. No subscriptions, just friction-less consumption. Why Hedera: By shifting from lumpy NFT mints to granular x402 micropayments, we turn reading into a metered stream. This enables 'Progressive Publishing' where authors earn while they write, and readers only pay for what they actually finish. Market: TAM $28B (Global eBook and digital publishing industry). | SAM $1.2B (The emerging web3 fiction and serial literature market). | SOM $85M (Indie authors on Hedera using pay-per-chapter monetization models). ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Inkstream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless publishing protocol where readers stream long-form fiction. Readers pay 0.05 USDC per chapter to unlock the next 'beat' in an evolving draft. Authors receive instant settlement for every page turn, while AI summarizers pay 0.01 USDC per call to index the plot for hyper-personalized discovery. No subscriptions, just friction-less consumption. Discipline: Writing, Poetry & Narrative (long-form fiction). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from lumpy NFT mints to granular x402 micropayments, we turn reading into a metered stream. This enables 'Progressive Publishing' where authors earn while they write, and readers only pay for what they actually finish. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Inkstream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-freeverse-forge-6-x402 Title: Freeverse Forge · x402 Theme: Writing, Poetry & Narrative (writing) · experimental poetry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A generative experimental poetry engine where every line-break is a micro-transaction. Poets pay 0.01 USDC to 'strike the forge,' triggering an LLM to evolve their draft based on avant-garde constraints (Oulipo, cut-up technique, or blackout). The result is a cryptographically signed, provenance-backed poem where the payment hash confirms the moment of inspiration. Why Hedera: By turning the act of generation into a pay-per-call primitive, we eliminate 'endless low-quality noise' and treat the AI interaction as a physical resource (ink/paper). The HTS transfer flow ensures fluid creative momentum without gas-fee friction. Market: TAM $2.5B — The global market for digital literature, self-publishing, and AI-assisted creative tools. | SAM $180M — The digital collectibles and indie publishing niche within the creator economy. | SOM $12M — Experimental writers and generative artists using Base for high-frequency micro-onchain activity. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Freeverse Forge" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A generative experimental poetry engine where every line-break is a micro-transaction. Poets pay 0.01 USDC to 'strike the forge,' triggering an LLM to evolve their draft based on avant-garde constraints (Oulipo, cut-up technique, or blackout). The result is a cryptographically signed, provenance-backed poem where the payment hash confirms the moment of inspiration. Discipline: Writing, Poetry & Narrative (experimental poetry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the act of generation into a pay-per-call primitive, we eliminate 'endless low-quality noise' and treat the AI interaction as a physical resource (ink/paper). The HTS transfer flow ensures fluid creative momentum without gas-fee friction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Freeverse Forge" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-dialogue-drop-7-x402 Title: ScriptStream · x402 Theme: Writing, Poetry & Narrative (writing) · scriptwriting dialogue Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to unlock an AI-generated 'rebuttal' or 'follow-up' line for your script scene—or charge 0.01 USDC to allow other writers to 'borrow' a line of your dialogue for their own drafts. Every line is a micro-transactional asset that settles instantly on-chain, creating a liquid market for high-impact beats. Why Hedera: By moving from 'NFT minting' to 'per-line micro-payments,' the friction of rights management is replaced by a high-velocity stream of usage revenue. It turns dialogue from a static archive into a live, metered API for narrative construction. Market: TAM $2.1B — The global scriptwriting and IP licensing market for film, TV, and gaming. | SAM $350M — The focused market for indie screenwriters, playwrights, and narrative designers using collaborative tools. | SOM $12M — Early-stage script fragments traded via x402-enabled collaborative writing environments on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "ScriptStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to unlock an AI-generated 'rebuttal' or 'follow-up' line for your script scene—or charge 0.01 USDC to allow other writers to 'borrow' a line of your dialogue for their own drafts. Every line is a micro-transactional asset that settles instantly on-chain, creating a liquid market for high-impact beats. Discipline: Writing, Poetry & Narrative (scriptwriting dialogue). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'NFT minting' to 'per-line micro-payments,' the friction of rights management is replaced by a high-velocity stream of usage revenue. It turns dialogue from a static archive into a live, metered API for narrative construction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "ScriptStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-mythos-mint-8-x402 Title: Codex Gate · x402 Theme: Writing, Poetry & Narrative (writing) · world-building lore Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Lore-as-a-Service for persistent universes. Instead of speculative minting, writers gate world-building entries (timelines, characters, magic systems) behind 0.01 USDC x402 signatures. Readers and developers pay per 'reveal' to integrate your lore into their campaigns or games. Every canon lookup is a direct micro-settlement to the author. Why Hedera: Shifts the value from 'owning an asset' to 'accessing the source of truth.' In world-building, the value is in the reference. x402 enables a metered 'Lore Oracle' where creators get paid every time a fan or a game engine queries their world's bible. Market: TAM $2.8B — Global tabletop, gaming, and fan-fiction IP markets moving toward decentralized licensing. | SAM $450M — Narrative designers and indie RPG developers using modular lore. | SOM $12M — Early-adopter world-builders on Hedera and Farcaster 'frames' using gated narrative. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Codex Gate" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Lore-as-a-Service for persistent universes. Instead of speculative minting, writers gate world-building entries (timelines, characters, magic systems) behind 0.01 USDC x402 signatures. Readers and developers pay per 'reveal' to integrate your lore into their campaigns or games. Every canon lookup is a direct micro-settlement to the author. Discipline: Writing, Poetry & Narrative (world-building lore). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the value from 'owning an asset' to 'accessing the source of truth.' In world-building, the value is in the reference. x402 enables a metered 'Lore Oracle' where creators get paid every time a fan or a game engine queries their world's bible. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Codex Gate" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-sonnet-seal-9-x402 Title: Sonnet Seal · x402 Theme: Writing, Poetry & Narrative (writing) · classic poetry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to break the seal and read a verified sonnet from the world's leading poets. Every line is an encrypted state, decrypted only upon payment, ensuring poets receive direct settlement for every 'read' rather than a vague platform royalty. Readers can pay an additional micro-fee to 'Endorse' a stanza, adding their cryptographic signature to the poem's history. Why Hedera: Classic poetry is high-value, low-volume content. Shifting from 'purchasing an NFT' to 'paying per read' lowers the barrier for consumers while creating a continuous revenue stream for writers. x402 allows for metered access to a library of high-culture works without subscription fatigue. Market: TAM $1.8B — The global digital publishing and subscription economy transitioning to pay-per-content primitives. | SAM $240M — The digital literary and independent publishing market moving toward micro-monetization models. | SOM $8.5M — Niche elite poetry circles and classic literature enthusiasts utilizing Base for immutable, paid preservation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Sonnet Seal" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to break the seal and read a verified sonnet from the world's leading poets. Every line is an encrypted state, decrypted only upon payment, ensuring poets receive direct settlement for every 'read' rather than a vague platform royalty. Readers can pay an additional micro-fee to 'Endorse' a stanza, adding their cryptographic signature to the poem's history. Discipline: Writing, Poetry & Narrative (classic poetry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Classic poetry is high-value, low-volume content. Shifting from 'purchasing an NFT' to 'paying per read' lowers the barrier for consumers while creating a continuous revenue stream for writers. x402 allows for metered access to a library of high-culture works without subscription fatigue. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Sonnet Seal" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-plotpoint-proof-10-x402 Title: BeatBank · x402 Theme: Writing, Poetry & Narrative (writing) · story plotting Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A metered narrative architect. Instead of minting a static NFT, authors pay 0.01 USDC to cryptographically timestamp a plot beat, branching path, or character arc. Each payment generates a signed attestation on Hedera, creating an immutable, granular audit trail of a story's evolution. Writers use it to prove 'First to Plot' in IP disputes, and AI narrative agents use it to purchase human-verified story frameworks for procedural generation. Why Hedera: Shifts from a one-time NFT mint to a high-frequency micro-transaction model that tracks the creative process itself. This turns the 'proof' into a utility-grade ledger of creative labor. Market: TAM $3.8B — The global IP protection and digital publishing market, increasingly automated by AI agents. | SAM $420M — Professional novelists, screenwriters, and tabletop RPG designers requiring verifiable IP provenance. | SOM $15M — Early-adopter web3 writers and AI-assisted narrative studios on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "BeatBank" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A metered narrative architect. Instead of minting a static NFT, authors pay 0.01 USDC to cryptographically timestamp a plot beat, branching path, or character arc. Each payment generates a signed attestation on Hedera, creating an immutable, granular audit trail of a story's evolution. Writers use it to prove 'First to Plot' in IP disputes, and AI narrative agents use it to purchase human-verified story frameworks for procedural generation. Discipline: Writing, Poetry & Narrative (story plotting). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from a one-time NFT mint to a high-frequency micro-transaction model that tracks the creative process itself. This turns the 'proof' into a utility-grade ledger of creative labor. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "BeatBank" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-haiku-hub-11-x402 Title: Haiku Ghost · x402 Theme: Writing, Poetry & Narrative (writing) · micro-poetry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Reveal the breath. A friction-less reader-mode for micro-poetry where every line is obscured by a digital shroud. Pay-per-stanza to unmask the verse. Authors bypass the subscription grind, receiving instant USDC settlement as readers consume prose one line at a time. No minting fees, no gas-heavy NFTs—just pure, metered narrative consumption. Why Hedera: Shifts the value from 'static ownership' to 'active consumption.' In a world of short attention spans, paying $0.01 per poem is a lower psychological barrier than minting an NFT, turning casual readers into micro-patrons. Market: TAM $4.2B — The global independent publishing and creator economy. | SAM $140M — The digital literary and 'Insta-poetry' market, shifting toward direct creator monetization. | SOM $2.8M — Mobile-first poetry enthusiasts and AI-curated narrative feeds on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Haiku Ghost" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Reveal the breath. A friction-less reader-mode for micro-poetry where every line is obscured by a digital shroud. Pay-per-stanza to unmask the verse. Authors bypass the subscription grind, receiving instant USDC settlement as readers consume prose one line at a time. No minting fees, no gas-heavy NFTs—just pure, metered narrative consumption. Discipline: Writing, Poetry & Narrative (micro-poetry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the value from 'static ownership' to 'active consumption.' In a world of short attention spans, paying $0.01 per poem is a lower psychological barrier than minting an NFT, turning casual readers into micro-patrons. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Haiku Ghost" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-scriptsync-12-x402 Title: DraftBeat · x402 Theme: Writing, Poetry & Narrative (writing) · collaborative scripts Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A collaborative screenplay engine where every line of dialogue or scene header is a micro-transaction. Writers commit 'beats' to a shared master-script by signing 0.01 USDC payloads. Each payment acts as a cryptographic timestamp and stake in the final IP. The script isn't 'saved'; it's streamed onto Base, creating a real-time, tamper-proof audit trail of authorship and ownership distribution for future royalties. Why Hedera: By turning the act of writing into a series of micropayments, we solve the 'attribution' crisis in collaborative writing. The HTS transfer flow ensures that the person who paid for the block is the one who owns the credit, effectively building the cap table as the script is written. Market: TAM $12B — Global media and entertainment scriptwriting software and intellectual property rights management. | SAM $180M — Independent screenwriters, playwrights, and digital content houses transitioning to decentralized IP. | SOM $4.2M — Early-stage script incubators and DAOs looking for transparent, automated co-authorship frameworks. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "DraftBeat" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A collaborative screenplay engine where every line of dialogue or scene header is a micro-transaction. Writers commit 'beats' to a shared master-script by signing 0.01 USDC payloads. Each payment acts as a cryptographic timestamp and stake in the final IP. The script isn't 'saved'; it's streamed onto Base, creating a real-time, tamper-proof audit trail of authorship and ownership distribution for future royalties. Discipline: Writing, Poetry & Narrative (collaborative scripts). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning the act of writing into a series of micropayments, we solve the 'attribution' crisis in collaborative writing. The HTS transfer flow ensures that the person who paid for the block is the one who owns the credit, effectively building the cap table as the script is written. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "DraftBeat" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-prose-provenance-13-x402 Title: InkStream · x402 Theme: Writing, Poetry & Narrative (writing) · short story writing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: $0.01 — Access the void. A metered narrative engine where every paragraph is a micro-transaction. Readers pay-per-scroll to reveal the next beat of a story, while authors receive instant HTS transfer settlements. No subscriptions, no ads, just pure narrative liquidity. Agents can query specific plot points or character data by signing 0.01 USDC authorizations, turning static stories into queryable narrative APIs. Why Hedera: Moving from 'NFT ownership' to 'metered access' solves the vanity-plate problem of NFTs. x402 allows for granular narrative consumption (pay-per-chapter or pay-per-twist), creating a direct financial link between pacing and profit. Market: TAM $4.2B — Global digital short-form content and serialized fiction markets. | SAM $850M — The independent digital publishing market and growing Web3 literary fiction sector. | SOM $12M — Micro-fiction enthusiasts and AI narrative agents operating on Hedera testnet. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkStream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT $0.01 — Access the void. A metered narrative engine where every paragraph is a micro-transaction. Readers pay-per-scroll to reveal the next beat of a story, while authors receive instant HTS transfer settlements. No subscriptions, no ads, just pure narrative liquidity. Agents can query specific plot points or character data by signing 0.01 USDC authorizations, turning static stories into queryable narrative APIs. Discipline: Writing, Poetry & Narrative (short story writing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from 'NFT ownership' to 'metered access' solves the vanity-plate problem of NFTs. x402 allows for granular narrative consumption (pay-per-chapter or pay-per-twist), creating a direct financial link between pacing and profit. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkStream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-memoir-mint-14-x402 Title: Epilogue · x402 Theme: Writing, Poetry & Narrative (writing) · personal narratives Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay 0.01 USDC to unlock a single, raw page from a stranger's life. Memoirists publish their narratives as a stream of metered, crypographically-signed entries. Instead of 'buying' a book, readers micro-stream the lived experience, ensuring the author is paid for every single paragraph consumed. Built for the 'attention-as-equity' era where personal truth is a high-frequency asset. Why Hedera: Shifts from static NFT ownership to metered consumption. HTS transfer auth ensures readers pay per 'page-turn' or 'memory-unlock', creating a direct, frictionless value flow between narrator and listener without the friction of a full book purchase. Market: TAM $12B — The global digital publishing and audiobook market, increasingly shifting toward micro-content and subscription-fatigue alternatives. | SAM $1.5B — The growing market for creator-direct publishing, digital memoirs, and premium long-form newsletters (Substack/Ghost). | SOM $28M — Early adopters in the crypto-literary space and creators seeking granular monetization of serial narratives on experimental L2s. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Epilogue" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay 0.01 USDC to unlock a single, raw page from a stranger's life. Memoirists publish their narratives as a stream of metered, crypographically-signed entries. Instead of 'buying' a book, readers micro-stream the lived experience, ensuring the author is paid for every single paragraph consumed. Built for the 'attention-as-equity' era where personal truth is a high-frequency asset. Discipline: Writing, Poetry & Narrative (personal narratives). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts from static NFT ownership to metered consumption. HTS transfer auth ensures readers pay per 'page-turn' or 'memory-unlock', creating a direct, frictionless value flow between narrator and listener without the friction of a full book purchase. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Epilogue" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-lyric-link-15-x402 Title: Ghostwrite · x402 Theme: Writing, Poetry & Narrative (writing) · songwriting lyrics Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A developer-facing API and lyricist playground where every 'Save to Ledger' or 'Generate Rhyme' action triggers a 0.01 USDC settlement. Lyricists earn real-time royalties as composers 'unlock' full stanzas for production. IP protection is no longer a static mint; it is an active, metered stream of authorship proving presence via Base transaction hashes. Why Hedera: Static NFTs are dead weight for songwriters who need active version control and micro-licensing. x402 turns every lyric fragment into a paid asset, allowing songwriters to monetize the drafting process and providing composers a frictionless 'pay-as-you-hum' model for high-quality hooks without upfront legal friction. Market: TAM $3.8B — The global music publishing and creative intellectual property market. | SAM $420M — The independent songwriter and session musician market shifting toward decentralized micro-licensing. | SOM $12M — Early-adopter lyricists on Hedera and Farcaster using metered tools for collaboration. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Ghostwrite" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A developer-facing API and lyricist playground where every 'Save to Ledger' or 'Generate Rhyme' action triggers a 0.01 USDC settlement. Lyricists earn real-time royalties as composers 'unlock' full stanzas for production. IP protection is no longer a static mint; it is an active, metered stream of authorship proving presence via Base transaction hashes. Discipline: Writing, Poetry & Narrative (songwriting lyrics). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Static NFTs are dead weight for songwriters who need active version control and micro-licensing. x402 turns every lyric fragment into a paid asset, allowing songwriters to monetize the drafting process and providing composers a frictionless 'pay-as-you-hum' model for high-quality hooks without upfront legal friction. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Ghostwrite" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrator-s-nod-16-x402 Title: VOX LOG · x402 Theme: Writing, Poetry & Narrative (writing) · audiobook scripts Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Micro-licensing for voices. A headless script engine where voice-over artists and AI agents pay 0.01 USDC per paragraph to stream high-fidelity, rights-cleared audiobook scripts. No subscriptions; pay only for the words you record. Each payment triggers a Hedera transaction id serving as a real-time performance license and authorship royalty. Why Hedera: By shifting from static NFT minting to pay-per-use metering, authors receive immediate liquidity at the point of consumption, and narrators avoid heavy upfront licensing fees for scripts they might not finish. Market: TAM $5.2B — The global digital publishing and spoken-word content market. | SAM $420M — The independent audiobook production and voice-over licensing market. | SOM $18M — The emerging 'AI Narrator' economy requiring automated, legally-compliant script access via API. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VOX LOG" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Micro-licensing for voices. A headless script engine where voice-over artists and AI agents pay 0.01 USDC per paragraph to stream high-fidelity, rights-cleared audiobook scripts. No subscriptions; pay only for the words you record. Each payment triggers a Hedera transaction id serving as a real-time performance license and authorship royalty. Discipline: Writing, Poetry & Narrative (audiobook scripts). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By shifting from static NFT minting to pay-per-use metering, authors receive immediate liquidity at the point of consumption, and narrators avoid heavy upfront licensing fees for scripts they might not finish. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VOX LOG" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-plot-mint-17-x402 Title: PlotProof · x402 Theme: Writing, Poetry & Narrative (writing) · story idea validation Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Pay-per-query validation for story premises against a global narrative database. Writers sign with the embedded wallet to pay 0.01 USDC and run their logline through an LLM that checks for tropes, clichés, and existing IP. Every check settles on-base, providing a cryptographic receipt of 'narrative proof-of-work' and creative timestamping. Eliminate the friction of minting; focus on the utility of verification. Why Hedera: Shifts the value from a static NFT 'claim' (which often holds little legal weight) to a metered utility service. By charging per validation call, the platform creates a high-velocity feedback loop for creators while establishing an on-chain ledger of creative provenance via settlement hashes. Market: TAM $4.2B — The global creative writing and scriptwriting software market. | SAM $850M — The digital publishing and self-publishing market where creators seek IP protection. | SOM $12M — Web3 writers and narrative designers utilizing Base for low-cost creative automation. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "PlotProof" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Pay-per-query validation for story premises against a global narrative database. Writers sign with the embedded wallet to pay 0.01 USDC and run their logline through an LLM that checks for tropes, clichés, and existing IP. Every check settles on-base, providing a cryptographic receipt of 'narrative proof-of-work' and creative timestamping. Eliminate the friction of minting; focus on the utility of verification. Discipline: Writing, Poetry & Narrative (story idea validation). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the value from a static NFT 'claim' (which often holds little legal weight) to a metered utility service. By charging per validation call, the platform creates a high-velocity feedback loop for creators while establishing an on-chain ledger of creative provenance via settlement hashes. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "PlotProof" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-epic-endorse-18-x402 Title: InkVerify · x402 Theme: Writing, Poetry & Narrative (writing) · literary peer review Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: An on-chain literary salon where every critique is a micro-settlement. Authors pay 0.01 USDC to unlock an anonymous, high-signal peer review, and reviewers earn instant liquidity for quality feedback. No fluff, no gatekeepers—just meritocratic editing where every 'red pen' stroke is a verified transaction. Why Hedera: By turning peer review into a pay-per-read/pay-per-critique model, we eliminate the 'request' friction. The x402 protocol ensures that literary labor is compensated at the atomic level, turning critique from a favor into a scalable service. Market: TAM $2.1B — The global technical writing, editing, and professional proofreading industry. | SAM $450M — The independent publishing and academic peer-review market moving toward decentralized verification. | SOM $12M — Web3-native novelists, experimental poets, and DAO-based researchers on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "InkVerify" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT An on-chain literary salon where every critique is a micro-settlement. Authors pay 0.01 USDC to unlock an anonymous, high-signal peer review, and reviewers earn instant liquidity for quality feedback. No fluff, no gatekeepers—just meritocratic editing where every 'red pen' stroke is a verified transaction. Discipline: Writing, Poetry & Narrative (literary peer review). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By turning peer review into a pay-per-read/pay-per-critique model, we eliminate the 'request' friction. The x402 protocol ensures that literary labor is compensated at the atomic level, turning critique from a favor into a scalable service. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "InkVerify" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-narrative-nexus-19-x402 Title: GhostWriter · x402 Theme: Writing, Poetry & Narrative (writing) · game narrative design Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-branch narrative engine where game designers charge per story node traversal. Instead of static NFTs, writers monetize the 'Decision Flow'. Developers pay 0.01 USDC to pull the next narrative state, variable set, or dialogue tree directly from the writer's authenticated logic. Pay-per-read triggers instant settlement for the creative lead, turning interactive scripts into metered APIs for indie games. Why Hedera: Moving from NFT minting to x402-native micro-metering allows for granular narrative consumption. In game dev, assets are often bloated; this trims the cost to only the paths players actually take, while providing the writer with immediate, high-frequency revenue. Market: TAM $180B — The global gaming industry transitioning toward decentralised asset management and automated logic calls. | SAM $1.2B — The growing market for interactive fiction, visual novels, and indie narrative middleware. | SOM $15M — Solo narrative designers and small game studios using modular/AI-assisted branching systems on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "GhostWriter" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-branch narrative engine where game designers charge per story node traversal. Instead of static NFTs, writers monetize the 'Decision Flow'. Developers pay 0.01 USDC to pull the next narrative state, variable set, or dialogue tree directly from the writer's authenticated logic. Pay-per-read triggers instant settlement for the creative lead, turning interactive scripts into metered APIs for indie games. Discipline: Writing, Poetry & Narrative (game narrative design). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moving from NFT minting to x402-native micro-metering allows for granular narrative consumption. In game dev, assets are often bloated; this trims the cost to only the paths players actually take, while providing the writer with immediate, high-frequency revenue. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "GhostWriter" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-poetprints-20-x402 Title: Stanza · x402 Theme: Writing, Poetry & Narrative (writing) · poetry licensing Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Metered verse for the agentic era. Pay 0.01 USDC to unlock an authorized usage license for a single poem, allowing AI narrators or digital publishers to pull text via per-call micro-settlement. Use signatures to prove provenance and licensing rights instantly. Why Hedera: Shifts the model from static 'minting' to active 'metering.' By pricing every read/extraction at $0.01, poetry becomes high-frequency liquid content suitable for LLM context windows or automated audio generation. Market: TAM $1.2B — Global digital publishing and licensing infrastructure. | SAM $140M — The emerging market for licensed data used in generative AI fine-tuning and inference. | SOM $2.5M — Niche poetry-to-audio agent integrations and web3 literary salons. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stanza" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Metered verse for the agentic era. Pay 0.01 USDC to unlock an authorized usage license for a single poem, allowing AI narrators or digital publishers to pull text via per-call micro-settlement. Use signatures to prove provenance and licensing rights instantly. Discipline: Writing, Poetry & Narrative (poetry licensing). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Shifts the model from static 'minting' to active 'metering.' By pricing every read/extraction at $0.01, poetry becomes high-frequency liquid content suitable for LLM context windows or automated audio generation. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stanza" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-dialogue-dao-21-x402 Title: VOX POPULI · x402 Theme: Writing, Poetry & Narrative (writing) · scriptwriting collaboration Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: Scriptwriting as a high-frequency micro-transaction layer. Writers pay 0.05 USDC to 'Speak' as a specific character in a collaborative session. These signed HTS transfer messages act as atomic dialogue beats—immediately settling royalties to the scene's architect while permanently anchoring the contribution to the script's linear timeline. No bulk subscriptions; you pay for the lines you write, and earn from the scenes you own. Why Hedera: Traditional DAOs suffer from governance bloat; x402 turns dialogue into a real-time metered economy where 'Skin in the Game' means paying a nickel to influence the narrative arc, ensuring quality and instant settlement. Market: TAM $4.2B — The total creator economy segment for script, playwriting, and episodic narrative content. | SAM $850M — The global screenwriting and collaborative narrative software market, pivoting toward decentralized AI-human workflows. | SOM $12M — High-velocity creative writing rooms and indie production houses on Hedera utilizing Hedera testnet for low-gas settlement. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "VOX POPULI" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT Scriptwriting as a high-frequency micro-transaction layer. Writers pay 0.05 USDC to 'Speak' as a specific character in a collaborative session. These signed HTS transfer messages act as atomic dialogue beats—immediately settling royalties to the scene's architect while permanently anchoring the contribution to the script's linear timeline. No bulk subscriptions; you pay for the lines you write, and earn from the scenes you own. Discipline: Writing, Poetry & Narrative (scriptwriting collaboration). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional DAOs suffer from governance bloat; x402 turns dialogue into a real-time metered economy where 'Skin in the Game' means paying a nickel to influence the narrative arc, ensuring quality and instant settlement. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "VOX POPULI" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-musemint-22-x402 Title: Muse · x402 Theme: Writing, Poetry & Narrative (writing) · creative prompts Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A headless creative catalyst. Pay 0.01 USDC to trigger a high-entropy narrative prompt. Every unlock grants the writer proof-of-inspiration on-chain, while facilitating a direct micro-rebate to the prompt's original architect. No subscriptions, just $0.01 per spark to break writer's block. Why Hedera: By moving from 'minting for provenance' to 'paying for triggers,' Muse becomes a utility. x402 handles the granular distribution of royalties to prompt engineers instantly, making the act of seeking inspiration a sustainable micro-transaction rather than a speculative NFT play. Market: TAM $2.4B — The global creative writing software and generative AI content market. | SAM $120M — Professional copywriters, screenwriters, and hobbyist authors utilizing AI-assisted ideation tools. | SOM $8M — Frictionless, per-prompt monetization for the emerging 'Prompt Engineering' and creative writing sub-communities on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Muse" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A headless creative catalyst. Pay 0.01 USDC to trigger a high-entropy narrative prompt. Every unlock grants the writer proof-of-inspiration on-chain, while facilitating a direct micro-rebate to the prompt's original architect. No subscriptions, just $0.01 per spark to break writer's block. Discipline: Writing, Poetry & Narrative (creative prompts). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: By moving from 'minting for provenance' to 'paying for triggers,' Muse becomes a utility. x402 handles the granular distribution of royalties to prompt engineers instantly, making the act of seeking inspiration a sustainable micro-transaction rather than a speculative NFT play. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Muse" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-fiction-flow-23-x402 Title: Inkstream · x402 Theme: Writing, Poetry & Narrative (writing) · serialized fiction Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A protocol for metered storytelling. Readers sign a session to stream serialized fiction at 0.01 USDC per page or plot-branch. Authors bypass subscription friction, converting casual readers into micro-patrons. No ads, no monthly fees, just a raw value exchange for every word consumed. Why Hedera: Traditional serialization relies on ads or high-friction monthly subs. x402 turns narrative consumption into a tiny, continuous stream of settlement, allowing authors to monetize viral momentum instantly without 'NFT collection' fatigue. Market: TAM $15.5B — The global digital publishing and e-book market as it shifts toward micro-transactional models and AI-agent readers. | SAM $2.4B — The addressable market for indie authors and digital platforms like Substack, Wattpad, and Kindle Vella. | SOM $12M — The initial niche of web3-native fiction readers and creative technologists on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Inkstream" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A protocol for metered storytelling. Readers sign a session to stream serialized fiction at 0.01 USDC per page or plot-branch. Authors bypass subscription friction, converting casual readers into micro-patrons. No ads, no monthly fees, just a raw value exchange for every word consumed. Discipline: Writing, Poetry & Narrative (serialized fiction). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Traditional serialization relies on ads or high-friction monthly subs. x402 turns narrative consumption into a tiny, continuous stream of settlement, allowing authors to monetize viral momentum instantly without 'NFT collection' fatigue. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Inkstream" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 ------------------------------------------------------------------------------ IDEA writing-verse-vault-24-x402 Title: Stanza · x402 Theme: Writing, Poetry & Narrative (writing) · spoken word poetry Hedera hook: Hedera testnet + x402 paywall [x402 native] Pitch: A pay-per-listen archive for spoken word. Users pay 0.01 USDC to unlock a high-fidelity vocal performance for a single session. Instead of speculative NFT ownership, creators earn immediate, liquid revenue every time a poem is heard. Narrative 'micro-metering' allows listeners to pay for a single stanza or the full performance, turning poetry into a streaming utility rather than a static asset. Why Hedera: Moves the value from 'provenance' (static) to 'consumption' (active). x402 eliminates the friction of secondary markets, rewarding poets directly for every individual ear they reach through headless, programmable micropayments. Market: TAM $1.2B — The total creator economy segment for spoken word, podcasts, and rhythmic narrative content. | SAM $340M — The digital audiobook and performance poetry streaming market. | SOM $12M — Independent spoken word artists moving from 'free social media' to 'paid-per-play' models on Hedera. ------------------------------------------------------------------------------ MEGAPROMPT (paste as a single Lovable message — it is self-contained): Build "Stanza" in ONE Lovable message. Single-page x402-native paid app on Hedera testnet. CONCEPT A pay-per-listen archive for spoken word. Users pay 0.01 USDC to unlock a high-fidelity vocal performance for a single session. Instead of speculative NFT ownership, creators earn immediate, liquid revenue every time a poem is heard. Narrative 'micro-metering' allows listeners to pay for a single stanza or the full performance, turning poetry into a streaming utility rather than a static asset. Discipline: Writing, Poetry & Narrative (spoken word poetry). Onchain primitive: x402 micropayments on Hedera testnet — pay per call in HBAR (native transfer) or HTS USDC 0.0.429274, verified on the mirror node by a facilitator you host yourself. Why: Moves the value from 'provenance' (static) to 'consumption' (active). x402 eliminates the friction of secondary markets, rewarding poets directly for every individual ear they reach through headless, programmable micropayments. 5-CREDIT BUDGET (HARD LIMIT) - ONE single-page app. No Lovable Cloud, no database, no auth beyond the Magic email OTP. - NO smart contract deploy. Payments settle in HBAR (native value transfer) or HTS USDC 0.0.429274 (ERC-20 `transfer` against 0x...068cda), both sent from the embedded wallet through the relay. - Self-host the facilitator as ONE TanStack server route inside the same app (shape below). - At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY, only if AI is part of the idea). - Skip tests, skip CI, skip docs. Ship the demo. STACK - React + Vite + TanStack Start (the template Lovable ships). - Magic Link embedded wallet (email OTP) + viem over the Hedera JSON-RPC relay. - Payments: HBAR value transfer, or ERC-20 `transfer` on the USDC EVM address. - Verification: mirror node REST only. Explorer links on every hash. HEDERA TESTNET FACTS (copy these verbatim — do not invent endpoints) - CAIP-2 network id: hedera:testnet - EVM chain id: 296 (0x128) - JSON-RPC relay: https://testnet.hashio.io/api <- use the ABSOLUTE url, see rule 2 - Mirror node REST: https://testnet.mirrornode.hedera.com/api/v1 - Explorer: https://hashscan.io/testnet (tx: /transaction/, topic: /topic/) - HBAR faucet: https://portal.hedera.com/faucet (free, instant, needs a portal login) - Testnet USDC faucet: https://faucet.circle.com/ -> pick "Hedera Testnet" - Testnet USDC: token id 0.0.429274 · EVM address 0x0000000000000000000000000000000000068cda · 6 decimals - HBAR has 8 decimals (tinybars) on-chain, but the JSON-RPC relay speaks 18-decimal weibars. - Every account has TWO address shapes: the long-zero form (0x + hex account num, left-padded to 40) and the ECDSA `evm_address` alias. GET /accounts/{idOrAddress} returns both (`account`, `evm_address`). Put all of this in src/data/hedera.json and read it from there. Never inline a relay url, token id, or payee address inside a component. CONFIG FILE (src/data/hedera.json) { "networkName": "Hedera Testnet", "caip2": "hedera:testnet", "chainId": 296, "jsonRpcRelay": "https://testnet.hashio.io/api", "mirrorNode": "https://testnet.mirrornode.hedera.com/api/v1", "explorer": "https://hashscan.io/testnet", "usdcTokenId": "0.0.429274", "usdcEvmAddress": "0x0000000000000000000000000000000000068cda", "usdcDecimals": 6, "magicPublishableKey": "pk_live_...", "payTo": "", "hbarFaucetUrl": "https://portal.hedera.com/faucet", "usdcFaucetUrl": "https://faucet.circle.com/" } WALLET + EMAIL ONBOARDING (Magic Link embedded wallet — this is the path that works) - Use magic-sdk with the Hedera testnet relay as its custom EVM transport, plus viem for reads. The Magic PUBLISHABLE key (pk_live_...) is public: keep it in src/data/hedera.json, NOT in secrets. import { Magic } from "magic-sdk"; const magic = new Magic(hedera.magicPublishableKey, { network: { rpcUrl: "https://testnet.hashio.io/api", chainId: 296 }, }); await magic.auth.loginWithEmailOTP({ email }); // email OTP, no seed phrase const provider = magic.rpcProvider; // EIP-1193 - BEFORE writing any wallet code, open the Magic dashboard and configure it (this is the single biggest time sink — no code change can work around it): Magic Dashboard -> your app -> Settings -> "Allowed Origins & Redirects": add your Lovable preview origin AND your published origin. Same page -> "Content Security Policy" -> add https://testnet.hashio.io Any RPC host that is not allow-listed is blocked INSIDE the wallet iframe and surfaces as `Magic RPC Error: [-32603] Failed to fetch`. The app-side fetch to the same host still works, which makes this look like a code bug for hours. It is not. - Mount all wallet code CLIENT-ONLY: lazy(() => import("./magic-hedera-entry")) inside + . The SDK touches window and crashes SSR at module scope. - Rebuild the Magic instance AND the viem client whenever the rpc url changes, keyed on the url. A cached SDK instance keeps its original transport, so a corrected url never takes effect and you keep staring at the same stale "Failed to fetch". - Do NOT proxy the relay through your own /api route for the wallet. A same-origin path is unreachable from inside the Magic iframe even though it is perfect for app-side reads. - Read the address defensively: the SDK's user metadata can lag. Fall back to `provider.request({ method: "eth_accounts" })` before rendering "unknown address". - ONBOARDING UX (learned the hard way): do not block the demo behind a "provisioning wallet..." spinner. A brand-new Hedera account only exists once it is funded, and mirror-node indexing takes seconds. Sign the user in immediately, show a "needs funding" state, and let them explore. - While signed in, ALWAYS render (outside any conditional funding block): * copyable Account ID (0.0.x) AND copyable EVM address (0x...) * HBAR balance and USDC balance * both faucet links, with the note: the Circle faucet wants the 0.0.x ACCOUNT ID, not the EVM address — funding the EVM address silently leaves the USDC balance at 0 * a "Refresh balance" button — balances are polled, never pushed. x402 ON HEDERA (there is no public Hedera facilitator — self-host both halves) Ship ONE TanStack server route that BOTH issues the 402 challenge and verifies settlement: src/routes/api/public/x402-paid-content.ts (the /api/public/ prefix skips site auth — do your own checks inside the handler). Challenge (HTTP 402 body) — offer BOTH assets, as the bounty asks: { "x402Version": 2, "accepts": [ { "scheme": "exact", "network": "hedera:testnet", "asset": "HBAR", "amount": "1000000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 HBAR)" }, { "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.429274", "amount": "10000", "payTo": "<0.0.x merchant>", "maxTimeoutSeconds": 300, "description": "Unlock one call (0.01 USDC)" } ] } HBAR amounts are TINYBARS (8 decimals). USDC amounts are ATOMIC units (6 decimals). Field name is `amount` (v2), not v1's `maxAmountRequired`. Match on scheme + network when picking. Headers are literal-cased and non-standard: send PAYMENT-SIGNATURE on the retry, read PAYMENT-RESPONSE off the 200. Read incoming headers case-insensitively, send exactly that casing. PAYMENT-SIGNATURE is base64 of: { "x402Version": 2, "accepted": { /* the accepts[] entry you chose, echoed verbatim */ }, "payload": { "txHash": "0x...", "payer": "0x... or 0.0.x", "asset": "HBAR" | "0.0.429274" } } ORDER OF OPERATIONS MATTERS: 1. GET the resource with no header -> 402 + accepts[] 2. user picks an asset -> submit the transfer from the embedded wallet (gas 900000n) 3. AWAIT CONSENSUS on the mirror node (poll, see verification block) 4. only THEN retry the resource with PAYMENT-SIGNATURE Retrying immediately returns `settlement_not_found` even though the payment is perfectly fine. Verify server-side per asset (see the verification block) and keep a Set of settled tx hashes for replay protection — a mirror record is public, so anyone could replay someone else's hash. GAS (silent killer) An HBAR transfer through the JSON-RPC relay needs ~900_000 gas. At 120_000 it reverts on-chain with INSUFFICIENT_GAS while the client-side call REPORTS SUCCESS and prints a hash — only HashScan shows the truth. Set `gas: 900000n` on every relay-sent value transfer and contract call from the start. MIRROR-NODE VERIFICATION (server-side only, never trust the client) - The mirror node LAGS consensus by seconds. Never read once after submitting: poll with a bounded retry loop (8-10 attempts, ~1.5s apart). Treat "not found yet" as pending, never as failure. - HBAR / native transfer: GET /transactions/{id} -> result === "SUCCESS", check payee, payer and amount in tinybars (8 decimals; 0.01 HBAR = 1000000 tinybars). - EVM call (any transfer sent through the relay): GET /contracts/results/{hash} -> status "0x1". - HTS USDC moved by an ERC-20 `transfer` through the relay: verify from the Transfer LOG on that EVM result. Do NOT try to hop to a consensus record — /contracts/results/{hash} has no `transaction_id`, and the parent consensus record's `token_transfers` array is EMPTY (the HTS movement lands on a child synthetic CRYPTOTRANSFER you cannot reach from the EVM hash). The authoritative signal ships with the EVM record: logs[i].address = 0x0000000000000000000000000000000000068cda (USDC) topics[0] = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef (Transfer) topics[1] = payer (32-byte left-padded) -> `0x${topic.slice(-40)}` topics[2] = payee (32-byte left-padded) -> `0x${topic.slice(-40)}` data = amount, atomic units (6 decimals; 0x2710 = 10000 = 0.01 USDC) Sum BigInt(log.data) over matching logs and compare against the required amount. - Match addresses against BOTH shapes (long-zero and alias) for payer and payee. `tx.from` uses one shape while event topics use the other; comparing a single shape produces false rejections such as `declared payer ... is not the sender`. - HTS tokens must be ASSOCIATED with an account before it can receive them. Gate the USDC path on an association step (HIP-719 / `associate`) or the transfer fails with TOKEN_NOT_ASSOCIATED_TO_ACCOUNT. - Return descriptive failure reasons (`invalid_payload: expected 10000 atomic units, saw 0`). The on-screen flow log is the only diagnostic your demo viewer has. FACILITATOR ROUTE (shape to ship) ```ts // src/routes/api/public/x402-paid-content.ts import { createFileRoute } from "@tanstack/react-router"; import hedera from "@/data/hedera.json"; const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const settled = new Set(); const unpad = (t: string) => `0x${t.slice(-40)}`.toLowerCase(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function poll(url: string, pick: (j: any) => T | null) { for (let i = 0; i < 10; i++) { const res = await fetch(url); if (res.ok) { const v = pick(await res.json()); if (v) return v; } await sleep(1500); } return null; } // both address shapes for one account, so payer/payee matching never false-negatives async function evmForms(id: string) { const r = await fetch(`${hedera.mirrorNode}/accounts/${id}`).then((x) => x.json()); const num = BigInt(String(r.account).split(".").pop()!); return new Set([`0x${num.toString(16).padStart(40, "0")}`, String(r.evm_address ?? "")] .filter(Boolean).map((s) => s.toLowerCase())); } export const Route = createFileRoute("/api/public/x402-paid-content")({ server: { handlers: { GET: async ({ request }) => { const accepts = [ { scheme: "exact", network: hedera.caip2, asset: "HBAR", amount: "1000000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 HBAR)" }, { scheme: "exact", network: hedera.caip2, asset: hedera.usdcTokenId, amount: "10000", payTo: hedera.payTo, maxTimeoutSeconds: 300, description: "Unlock one call (0.01 USDC)" }, ]; const header = request.headers.get("PAYMENT-SIGNATURE") ?? request.headers.get("payment-signature"); if (!header) return Response.json({ x402Version: 2, accepts }, { status: 402 }); const { accepted, payload } = JSON.parse(atob(header)); const want = accepts.find((a) => a.asset === accepted?.asset); if (!want) return Response.json({ error: "invalid_payload: unknown asset" }, { status: 402 }); if (settled.has(payload.txHash)) return Response.json({ error: "replayed_transaction" }, { status: 402 }); const payee = await evmForms(hedera.payTo); const result = await poll(`${hedera.mirrorNode}/contracts/results/${payload.txHash}`, (j) => (j?.status === "0x1" ? j : null)); if (!result) return Response.json({ error: "settlement_not_found: no successful EVM record yet" }, { status: 402 }); if (want.asset === "HBAR") { // relay reports weibars (18) -> tinybars (8) const tinybars = BigInt(result.amount ?? 0); if (!payee.has(String(result.to).toLowerCase()) || tinybars < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} tinybars to ${hedera.payTo}, saw ${tinybars}` }, { status: 402 }); } else { const paid = (result.logs ?? []).reduce((sum: bigint, log: any) => { const isUsdc = String(log.address).toLowerCase() === hedera.usdcEvmAddress.toLowerCase(); const isTransfer = String(log.topics?.[0]).toLowerCase() === TRANSFER_TOPIC; const toPayee = log.topics?.[2] && payee.has(unpad(log.topics[2])); return isUsdc && isTransfer && toPayee ? sum + BigInt(log.data) : sum; }, 0n); if (paid < BigInt(want.amount)) return Response.json({ error: `invalid_payload: expected ${want.amount} atomic units of USDC, saw ${paid}` }, { status: 402 }); } settled.add(payload.txHash); return new Response(JSON.stringify({ unlocked: true /* ...the paid payload... */ }), { status: 200, headers: { "Content-Type": "application/json", "PAYMENT-RESPONSE": btoa(JSON.stringify({ success: true, network: hedera.caip2, asset: want.asset, transaction: payload.txHash, explorer: `${hedera.explorer}/transaction/${payload.txHash}`, })), }, }); } } }, }); ``` BUILD / BUNDLE GOTCHAS - Hedera SDKs drag Node built-ins into the browser bundle. `No such module "node:process"` means you imported the Node build: use the SDK's web/browser entry and `WebClient`, and alias `pino` to its browser build in vite.config.ts: resolve: { alias: { pino: "pino/browser.js" } } - Server-only Hedera SDK work belongs in a *.server.ts module called from a server function; never import it from a component. - Read process.env INSIDE the handler of a server function, never at module scope. FILE LAYOUT src/data/hedera.json network + public config (above) src/data/x402.json challenge amounts per asset src/lib/magic.ts Magic + viem clients, rebuilt when the rpc url changes src/lib/x402.ts challenge fetch, envelope build, awaitSettlement poll src/components/magic-hedera-entry.tsx CLIENT-ONLY: email OTP, balances, associate, payHbar, payUsdc src/routes/api/public/x402-paid-content.ts self-hosted challenge + facilitator verify src/routes/index.tsx the demo: sign in -> fund -> unlock -> flow log USER FLOW (render every step as a live flow log — it is your demo video) 1. Land on the page -> "Sign in with email" -> Magic OTP -> signed in immediately (no blocking spinner). 2. Wallet card shows copyable Account ID + EVM address, HBAR and USDC balances, both faucet links and a Refresh balance button. If unfunded, show a "needs funding" hint instead of locking the UI. 3. Click "Unlock" -> the app GETs the resource, gets 402, and renders both prices from accepts[]. 4. Pick HBAR or USDC. For USDC, "Associate USDC" first if the account is not associated yet. 5. Submit the transfer (gas 900000n) -> hash appears in the log, linked to HashScan. 6. Poll the mirror node until consensus, then retry with PAYMENT-SIGNATURE. 7. Facilitator verifies and returns the unlocked "Stanza" payload + PAYMENT-RESPONSE (HashScan link). 8. Print facilitator error strings VERBATIM in the log — they are the only diagnostic on screen. 9. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14" FAILURE MODES (symptom -> cause -> fix) | Symptom | Cause | Fix | |---|---|---| | `Magic RPC Error: [-32603] Failed to fetch` | RPC host missing from Magic CSP, or you used a same-origin proxy path | allow-list the host in the Magic dashboard; pin the absolute Hashio url | | corrected RPC url still fails | cached Magic instance kept the old transport | rebuild Magic + viem clients when the url changes | | client says success, HashScan says FAILED | 120k gas on a relay transfer | raise gas to ~900k | | `expected 10000 atomic units ... saw 0` | read the parent record's empty `token_transfers` | verify from the ERC-20 Transfer log | | `settlement_not_found: no consensus transaction yet` | tried to hop to a consensus record from an EVM hash | same as above | | `declared payer ... is not the sender` | compared only long-zero or only alias | match both forms from /accounts/{id} | | USDC balance stays 0 after the faucet | funded the EVM address, or token not associated | use the 0.0.x Account ID; associate first | | `No such module "node:process"` | Node build of a Hedera SDK in the browser bundle | web build + WebClient; alias pino to browser | | `settlement_not_found` right after submit | read the mirror node once | poll with bounded retries | | blank page / `window is not defined` | wallet code imported at module scope of a route | lazy() inside | WHAT WE'D DO DIFFERENTLY (do these first, in this order) 1. Allow-list https://testnet.hashio.io + your origins in the Magic dashboard CSP BEFORE writing a line of wallet code. 2. Set gas to 900_000 on the very first relay transfer instead of debugging a "successful" revert. 3. Verify HTS-over-EVM from the Transfer log from the start; never chase consensus token_transfers. 4. Poll the mirror node with retries everywhere; never read once and conclude. 5. Render Account ID, EVM address, both balances and both faucet links unconditionally while signed in — half the debugging was people not knowing which address to fund. 6. Reach for native services (HTS for tokens, HCS for append-only logs/timestamps) before Solidity. Deploy a contract only when the logic itself is on-chain state no native service models. REQUIRED SECRETS (Lovable -> Project Settings -> Secrets) - HEDERA_OPERATOR_ID Merchant/operator testnet account id (0.0.xxxxxx): https://portal.hedera.com/faucet - HEDERA_OPERATOR_KEY ECDSA private key for that account: https://portal.hedera.com/dashboard (server-only: HTS association, HCS topic create + submit. Never ship it to the browser.) NOT secrets (public config, put them in src/data/hedera.json): - the Magic PUBLISHABLE key (pk_live_...) from https://dashboard.magic.link - the relay url, mirror node url, explorer url, USDC token id / EVM address, and payTo account. CREDIT (must appear in the UI footer, and as NatSpec if you deploy any contract): Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14